# github-webhook-server

> Keep GitHub pull requests moving with automated checks, approvals, labels, cherry-picks, and release workflows.

---

Source: quick-start.md

# Start Automating a Repository

You want one GitHub repository under automation quickly so new pull requests start getting webhook handling, labels, and review workflow help without manual setup in GitHub. This guide gets you from an empty data directory to a live webhook and a working health check in a few minutes.

## Prerequisites

- Python `3.14`
- `uv`
- A GitHub App ID
- The matching GitHub App private key in PEM format
- At least one GitHub token that can manage the target repository
- A repository where the GitHub App is installed
- A public URL GitHub can reach, or a `smee.io` channel for local testing

## Quick example

```bash
    export WEBHOOK_SERVER_DATA_DIR="$HOME/webhook-server-data"
    mkdir -p "$WEBHOOK_SERVER_DATA_DIR"

    cat > "$WEBHOOK_SERVER_DATA_DIR/config.yaml" <<'YAML'
    github-app-id: 123456
    github-tokens:
      - ghp_your_token_here

    webhook-ip: https://your-domain.example/webhook_server

    repositories:
      your-repo:
        name: your-org/your-repo
    YAML

    cp /path/to/your/github-app-private-key.pem \
      "$WEBHOOK_SERVER_DATA_DIR/webhook-server.private-key.pem"

    uv sync
    WEBHOOK_SERVER_DATA_DIR="$WEBHOOK_SERVER_DATA_DIR" uv run entrypoint.py
```

```bash
    curl http://127.0.0.1:5000/webhook_server/healthcheck
```

```json
    {"status":200,"message":"Alive"}
```

Once the server starts, it bootstraps the repository you listed: it creates or updates the webhook and prepares the built-in automation. The health check proves the process is running; the steps below confirm GitHub can actually reach it.

## Step-by-step

### 1. Create the data directory

```bash
    export WEBHOOK_SERVER_DATA_DIR="$HOME/webhook-server-data"
    mkdir -p "$WEBHOOK_SERVER_DATA_DIR"
```

Your minimal layout should look like this:

```text
    $WEBHOOK_SERVER_DATA_DIR/
    ├── config.yaml
    └── webhook-server.private-key.pem
```

> **Note:** If you do not set `WEBHOOK_SERVER_DATA_DIR`, the server uses `/home/podman/data`.

### 2. Add the smallest working `config.yaml`

```yaml
    github-app-id: 123456
    github-tokens:
      - ghp_your_token_here

    webhook-ip: https://your-domain.example/webhook_server

    repositories:
      your-repo:
        name: your-org/your-repo
```

Use these values carefully:

| Setting | What to put there |
|---|---|
| `github-app-id` | Your GitHub App ID |
| `github-tokens` | One or more GitHub tokens for repository administration |
| `webhook-ip` | The callback URL GitHub will call |
| `repositories.your-repo` | The short repository name, such as `your-repo` |
| `repositories.your-repo.name` | The full repository name, such as `your-org/your-repo` |

> **Warning:** For a normal public deployment, `webhook-ip` should include the full webhook path, for example `https://your-domain.example/webhook_server`.


> **Warning:** The key under `repositories` should be the short repository name, not `owner/repo`. In the example above, the key is `your-repo` and the full name belongs in `name`.


> **Warning:** GitHub cannot deliver webhooks to `localhost`. Use a public URL or the `smee.io` workflow in [Advanced Usage](#advanced-usage).


> **Tip:** You do not need repo-local overrides for the first run. See [Configure Repositories](configure-repositories.html) for global defaults, per-repo overrides, and self-service settings.

### 3. Add the GitHub App private key

```bash
    cp /path/to/your/github-app-private-key.pem \
      "$WEBHOOK_SERVER_DATA_DIR/webhook-server.private-key.pem"
```

The filename matters. The server looks for that exact name in the data directory.

Install the GitHub App on `your-org/your-repo` before you start the server. You need both the private key and at least one GitHub token for a working setup.

### 4. Install dependencies and start the server

```bash
    uv sync
    WEBHOOK_SERVER_DATA_DIR="$WEBHOOK_SERVER_DATA_DIR" uv run entrypoint.py
```

By default, the server listens on `0.0.0.0:5000`. On startup it creates or updates the repository webhook and prepares the repository for automation.

> **Tip:** If you only need one repository working quickly, omit optional keys like `events`, `pre-commit`, `tox`, `container`, and `pypi` until the webhook is live.

### 5. Verify the server is healthy

```bash
    curl http://127.0.0.1:5000/webhook_server/healthcheck
```

You should get:

```json
    {"status":200,"message":"Alive"}
```

A healthy response means the web process is running and listening on the webhook route.

> **Note:** The health check does not prove that GitHub can reach your `webhook-ip`. It only proves the server is up locally.

### 6. Verify GitHub can deliver to the webhook

After the server is running, check the delivery from GitHub:

1. Open the repository in GitHub.
2. Open the webhook or GitHub App delivery view for that repository.
3. Send a ping or redeliver the most recent test event.
4. Confirm the delivery gets an HTTP `200`.

If you see `200`, the server accepted the event and queued it for processing.

> **Note:** A `200` delivery means the event was accepted for background processing. It does not guarantee every automation step succeeded. See [Webhook and Health API](webhook-and-health-api.html) for the response behavior.

### 7. Prove the repository is actually automated

The fastest end-to-end proof is a tiny pull request.

1. Open a small PR against `your-org/your-repo`.
2. Wait for the server to process the `pull_request` event.
3. Confirm you see automation, such as a welcome comment, labels, or check runs on the PR.

If that works, the repository is connected and ready for deeper configuration. See [Manage Pull Requests](manage-pull-requests.html) for what happens on new PRs, see [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html) before rolling this out to a team, and see [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html) to add CI and release automation.

## Advanced Usage

### Add a webhook secret

```yaml
    github-app-id: 123456
    github-tokens:
      - ghp_your_token_here

    webhook-ip: https://your-domain.example/webhook_server
    webhook-secret: replace-this-with-a-shared-secret

    repositories:
      your-repo:
        name: your-org/your-repo
```

Add `webhook-secret` once the basic flow works. GitHub will sign deliveries, and the server will reject requests with the wrong signature.

> **Tip:** See [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) for production hardening after your first repository is connected.

### Use more than one GitHub token

```yaml
    github-tokens:
      - ghp_primary_token
      - ghp_backup_token
```

The server supports a token pool. This is useful if you want more rate-limit headroom or a backup token when one is exhausted.

### Test locally with `smee.io`

```yaml
    webhook-ip: https://smee.io/your-channel
```

```bash
    smee -u https://smee.io/your-channel -p 5000 -P /webhook_server
    WEBHOOK_SERVER_DATA_DIR="$WEBHOOK_SERVER_DATA_DIR" uv run entrypoint.py
```

This is the fastest way to test from a laptop when GitHub cannot reach your machine directly.

> **Warning:** For `smee.io`, use the channel URL as `webhook-ip`. Do not append `/webhook_server` to the `smee.io` URL, because the local forwarding command already adds that path.

### Change the bind address, port, or worker count

```yaml
    ip-bind: 0.0.0.0
    port: 8080
    max-workers: 20
```

Restart the server after changing these values. If you change `webhook-ip`, restart too so the startup bootstrap can update the repository webhook.

See [Environment Variables](environment-variables.html) for startup toggles such as log viewer and MCP support.

### Limit the events the repository processes

```yaml
    repositories:
      your-repo:
        name: your-org/your-repo
        events:
          - pull_request
          - issue_comment
          - push
```

If you omit `events`, the webhook is created with `*`. See [Supported GitHub Events](supported-github-events.html) to decide which events you actually want.

## Troubleshooting

- **The server says `config.yaml` was not found**
  - Make sure `WEBHOOK_SERVER_DATA_DIR` points to the directory that contains `config.yaml`.
  - Make sure the file is named exactly `config.yaml`.

- **The server starts, but repository automation never appears**
  - Check that the GitHub App is installed on the repository.
  - Check that the `repositories` key uses the short repo name and `name` uses the full `owner/repo` value.
  - Check that the private key file is named exactly `webhook-server.private-key.pem`.

- **GitHub deliveries fail even though the health check passes**
  - Make sure `webhook-ip` is publicly reachable.
  - Make sure a public deployment uses the full callback path ending in `/webhook_server`.
  - If you set `webhook-secret`, make sure GitHub is using the same shared secret.

- **GitHub shows `200`, but the PR still did not update**
  - The server accepts webhook events immediately and processes them in the background.
  - See [Debug with the Log Viewer](debug-with-the-log-viewer.html) to inspect delivery IDs, step failures, and log output.

## Related Pages

- [Configure Repositories](configure-repositories.html)
- [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html)
- [Manage Pull Requests](manage-pull-requests.html)
- [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html)
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html)


---

Source: configure-repositories.md

# Configure Repositories

```yaml
    # config.yaml
    github-app-id: 123456
    github-tokens:
      - <GITHUB TOKEN1>
      - <GITHUB TOKEN2>

    webhook-ip: https://your-domain.com/webhook_server

    default-status-checks:
      - "WIP"
      - "can-be-merged"

    repositories:
      my-repository:
        name: my-org/my-repository
        events:
          - pull_request
          - issue_comment
          - check_run
          - status
        tox:
          main: all
        pre-commit: true
        protected-branches:
          main:
            include-runs:
              - "pre-commit.ci - pr"
              - "WIP"
```

```yaml
    # .github-webhook-server.yaml
    conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,revert"
    minimum-lgtm: 2
    create-issue-for-new-pr: true
```

The first file gives the server everything it needs to bootstrap and manage the repository. The second file lets repository maintainers tune day-to-day behavior without editing the server.

## Step-by-step

### 1. Put shared defaults in `config.yaml`

Start with the settings that should apply everywhere unless a repository overrides them.

```yaml
    log-level: INFO
    log-file: webhook-server.log
    mask-sensitive-data: true

    github-app-id: 123456
    github-tokens:
      - <GITHUB TOKEN1>
      - <GITHUB TOKEN2>

    webhook-ip: https://your-domain.com/webhook_server

    default-status-checks:
      - "WIP"
      - "dpulls"
      - "can-be-merged"

    branch-protection:
      strict: true
      require_code_owner_reviews: true
      dismiss_stale_reviews: false
      required_approving_review_count: 1
      required_linear_history: true
      required_conversation_resolution: true
```

Use top-level settings for:

- Shared defaults across many repositories
- Secrets and credentials
- Startup-time behavior such as webhook creation and branch protection
- Organization-wide policies

> **Warning:** `webhook-ip` must include the full webhook path, for example `https://your-domain.com/webhook_server`.

### 2. Add each repository under `repositories`

Add one entry per managed repository. Use the short repository name as the key, and the full `owner/repo` name inside `name`.

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository

      another-repo:
        name: my-org/another-repo
```

Use this pattern when you want the same server to manage multiple repositories with shared defaults.

> **Warning:** The key under `repositories` should be the repository name that GitHub sends in webhook payloads, such as `my-repository`, not `my-org/my-repository`.

### 3. Add server-side overrides for repository-specific behavior

Put per-repository overrides directly inside the matching repository block when the setting is repo-specific, secret, or part of startup bootstrap.

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        log-level: DEBUG
        log-file: my-repository.log
        github-tokens:
          - <GITHUB TOKEN1>
        events:
          - push
          - pull_request
          - pull_request_review
          - pull_request_review_thread
          - issue_comment
          - check_run
          - status
        tox:
          main: all
          dev: testenv1,testenv2
        pre-commit: true
        protected-branches:
          main:
            include-runs:
              - "pre-commit.ci - pr"
              - "WIP"
            exclude-runs:
              - "SonarCloud Code Analysis"
```

This is the right place for:

- Repository-specific event subscriptions
- Dedicated tokens
- Protected branch rules
- Logging overrides
- Server-controlled credentials such as Slack webhooks, registry passwords, and PyPI tokens

If you want the repository to receive all events, omit `events` entirely instead of listing everything.

### 4. Let repository maintainers self-serve safe settings

Add a `.github-webhook-server.yaml` file at the repository root when maintainers should be able to adjust behavior without editing the server.

```yaml
    conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,revert"
    minimum-lgtm: 2
    pre-commit: true

    labels:
      enabled-labels:
        - verified
        - hold
        - size
      colors:
        hold: purple

    pr-size-thresholds:
      Quick:
        threshold: 20
        color: lightgreen
      Normal:
        threshold: 100
        color: green
```

This is a good fit for:

- PR title rules
- Minimum review thresholds
- Label behavior
- PR size labels
- Draft PR command allowances
- AI feature behavior
- Security-check behavior that repository maintainers are allowed to tune

> **Warning:** Do not commit secrets to `.github-webhook-server.yaml`. Keep tokens, passwords, and other credentials in server-side `config.yaml`.

### 5. Use the right config layer for the right job

Use this split when you are deciding where a setting belongs:

| Put the setting here | Best for | Typical examples |
|---|---|---|
| Top-level `config.yaml` | Shared defaults and org-wide policy | `default-status-checks`, `branch-protection`, `mask-sensitive-data` |
| `repositories.<repo-name>` in `config.yaml` | Server-side repo overrides and secrets | `github-tokens`, `protected-branches`, `slack-webhook-url`, `pypi`, `container` |
| `.github-webhook-server.yaml` | Safe maintainer-controlled behavior | `conventional-title`, `minimum-lgtm`, `labels`, `pr-size-thresholds` |

Precedence is highest to lowest:

| Priority | Source | Use it when |
|---|---|---|
| 1 | `.github-webhook-server.yaml` | Repository maintainers should control the setting |
| 2 | `repositories.<repo-name>` in `config.yaml` | The server should override defaults for one repository |
| 3 | Top-level `config.yaml` | The setting should apply everywhere unless overridden |

### 6. Restart when bootstrap settings change

Some settings are applied during startup bootstrap, not during normal PR processing. After changing these in `config.yaml`, restart the server.

Restart after changes to settings such as:

- `webhook-ip`
- `repositories`
- `events`
- `branch-protection`
- `protected-branches`

> **Note:** The repository-local `.github-webhook-server.yaml` file is used during webhook processing, but not for startup bootstrap. Put bootstrap-critical settings in `config.yaml`.

## Advanced Usage

### Keep secrets server-side and behavior repo-side

A practical split looks like this:

| Server-side only | Safe to hand to repository maintainers |
|---|---|
| `github-tokens` | `conventional-title` |
| `pypi.token` | `minimum-lgtm` |
| `container.password` | `labels` |
| `slack-webhook-url` | `pr-size-thresholds` |
| `github-app-id` | `allow-commands-on-draft-prs` |

This keeps credentials out of Git history while still letting teams tune workflow behavior.

### Use repository-specific tokens only where needed

If most repositories can share one token pool, keep `github-tokens` at the top level. Add a repository-specific token list only when a repository needs different permissions or rate-limit isolation.

```yaml
    github-tokens:
      - <GITHUB TOKEN1>
      - <GITHUB TOKEN2>

    repositories:
      my-repository:
        name: my-org/my-repository
        github-tokens:
          - <REPO SPECIFIC TOKEN>
```

### Override only the keys you need

The repository-local file does not need to repeat the full server config. Add only the keys you want to change.

```yaml
    # .github-webhook-server.yaml
    minimum-lgtm: 1
    allow-commands-on-draft-prs:
      - build-and-push-container
```

That keeps the repository config small and makes overrides easy to review.

### Use `protected-branches` to control required checks per branch

You can require all defaults, or include and exclude specific checks for one branch.

```yaml
    protected-branches:
      dev: []
      main:
        include-runs:
          - "pre-commit.ci - pr"
          - "WIP"
        exclude-runs:
          - "SonarCloud Code Analysis"
```

Use an empty list when you want the default required checks for that branch. Use `include-runs` and `exclude-runs` when one branch needs a different requirement set. `exclude-runs` is subtracted from the final list in both cases, so it also removes an entry that `include-runs` added.

### Hand off specialized settings to the right pages

Keep this page focused on where configuration lives and how it overrides. For the actual knobs in those feature areas, use the dedicated pages:

- See [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html) for `tox`, `pre-commit`, custom checks, container builds, and PyPI settings.
- See [Enable AI Features](enable-ai-features.html) for `ai-features` and `test-oracle`.
- See [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) for `security-checks`.
- See [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html) for `OWNERS`-driven review behavior.

## Troubleshooting

- **The server says the repository is not configured**
  - Check the key under `repositories`.
  - Make sure `name` contains the full `owner/repo`, but the map key is only the short repository name.

- **The repo-local file is ignored**
  - The file must be named exactly `.github-webhook-server.yaml`.
  - It must live at the repository root.
  - If you changed a bootstrap setting, move that change into `config.yaml` and restart the server.

- **A setting is not taking effect**
  - Check the precedence order: repo-local file overrides repository block, which overrides global defaults.
  - Remove duplicate definitions while debugging so you can see which layer is winning.

- **Branch protection or webhook subscriptions did not update**
  - Restart the server after changing `webhook-ip`, `events`, `branch-protection`, or `protected-branches`.

- **Config loads fail after editing labels**
  - Verify that `labels.enabled-labels` only uses supported label categories.
  - See [Configuration Reference](configuration-reference.html) for the allowed values.

## Related Pages

- [Configuration Reference](configuration-reference.html)
- [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html)
- [Enable AI Features](enable-ai-features.html)
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html)
- [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html)


---

Source: set-up-owners-and-reviewers.md

# Set Up OWNERS and Review Rules

The webhook server does not use GitHub's CODEOWNERS. It reads its own `OWNERS` files from
your repository and uses them to decide who must review a pull request, who counts as an
*approver*, who can run commands, and which reviewers get auto-assigned.

## What `OWNERS` controls

| Key | Effect |
| --- | --- |
| `approvers` | Members of this scope who can `/approve`. An approval from this list is required for the `can-be-merged` check only when the scope has at least one approver; a path with no approvers listed imposes no approval requirement. |
| `reviewers` | Members of this scope who are eligible for reviewer auto-assignment; `/lgtm` is not gated by membership. |
| `allowed-users` | Users (in addition to collaborators and contributors) who may run commands such as `/retest`, `/cherry-pick`, `/rebase`, `/check-can-merge`, `/assign-reviewers`. |
| `root-approvers` | Optional boolean. Set to `false` to opt out of requiring an approval from the root `OWNERS` approvers. |

## File discovery rules

These are exact — the parser is strict.

- The filename must be exactly `OWNERS` (uppercase, no extension). `OWNERS.yaml`, `owners`,
  `.github/CODEOWNERS`, and `OWNERS_ALIASES` are **not** read.
- Files are found recursively in the cloned repository, which is checked out at the **base
  branch** of the PR. An `OWNERS` file added only in the PR branch is ignored until merged.
- Any path with a dot-prefixed directory component (`.git`, `.venv`, `.github`, ...) is skipped.
- At most 1000 `OWNERS` files are processed per PR.
- Each file is keyed by its **parent directory**, not by file path. `webhook_server/OWNERS`
  is stored under `webhook_server`; the repo-root `OWNERS` is stored under `.`.

## Supported syntax

Each `OWNERS` file is a YAML **mapping**. If it is not a mapping, or if `approvers`,
`reviewers`, or `allowed-users` is present but is not a list of strings, the file is rejected
(logged as `Invalid OWNERS file <path>`) and contributes nothing. Unknown keys are allowed
and ignored by the validator, with the exception of `root-approvers`, which is meaningful.

```yaml
    approvers:
      - alice
      - bob
    reviewers:
      - carol
      - dave
```

A real example — this repository's own root `OWNERS`:

```yaml
    approvers:
      - myakove
      - rnetser
    reviewers:
      - myakove
      - rnetser
```

A subdirectory file with the opt-out flag:

```yaml
    root-approvers: false
    approvers:
      - docs-team
    reviewers:
      - writer1
      - writer2
    allowed-users:
      - "@contractor"
```

Notes on values:

- Usernames are **GitHub logins**, matched case-sensitively against labels such as
  `approved-alice`. Write them exactly as the login.
- `allowed-users` accepts a leading `@` (it is stripped), and is read **only from the repo-root
  `OWNERS`** — an `allowed-users` list in a subdirectory is ignored.
- `root-approvers` only affects a subdirectory file. Setting it in the root `OWNERS` has no
  effect, because the root entry is always included anyway.

## How owners are resolved for a PR

1. The webhook server clones the repo and runs
   `git diff --name-only <base_sha>...<head_sha>` to get the changed files. (It falls back to
   two-dot `..` if there is no merge base.)
2. It takes the parent directory of every changed file as the set of *changed folders*.
3. Every `OWNERS` file whose directory is equal to, or an ancestor of, a changed folder is
   matched. So `webhook_server/OWNERS` matches a change to `webhook_server/libs/config.py`, and
   also matches a change to `webhook_server/libs/OWNERS`'s subdirectories.
4. The root `OWNERS` (key `.`) is added unless a matched subdirectory file opts out with
   `root-approvers: false`. When several files match, the **first match processed** decides the
   opt-out, so don't mix opting-out and non-opting-out files in one PR.
5. `all_pull_request_approvers` / `all_pull_request_reviewers` are the de-duplicated unions of
   the approvers/reviewers from those matched files.

Separately, `all_repository_approvers` is the union of approvers from **every** `OWNERS` file in
the repository, regardless of what the PR touches. That repo-wide list is what makes a user
eligible to run commands; it is not what gates `/approve`.

## Review commands

### `/approve`

- Only works if the commenter is in `all_pull_request_approvers` (matched by the changed files)
  **or** in the root `OWNERS` approvers. Anyone else is silently ignored.
- Adds the label `approved-<login>` and removes `changes-requested-<login>`.
- `/approve cancel` removes it.
- A `/approve` line anywhere in a submitted pull-request review body does the same thing.
- Any `/approve` triggers the test oracle with `trigger="approved"`.

### `/lgtm`

- Adds `lgtm-<login>` and removes `changes-requested-<login>`.
- Available to **any** commenter except the PR author — it is not restricted to OWNERS
  reviewers. Whether the LGTM *counts* toward `minimum-lgtm` is a separate check.
- A GitHub review with state `approved` or `commented` also produces the `lgtm-<login>` label.
- A GitHub review with state `changes_requested` produces `changes-requested-<login>`, which
  blocks the merge check if that user is a matched approver.

### Who may run other commands

`/retest`, `/reprocess`, `/cherry-pick`, `/rebase`, `/check-can-merge`, `/assign-reviewer(s)`,
`/add-allowed-user`, `/regenerate-welcome`, `/build-and-push-container`, `/security-override`
require the commenter to be in the union of:

- repository collaborators,
- repository contributors,
- every approver in the repository (`all_repository_approvers`),
- the matched PR reviewers (`all_pull_request_reviewers`),
- root `OWNERS` `allowed-users`.

Otherwise a maintainer or approver can grant access per-PR by commenting
`/add-allowed-user @someone`. A `/hold` label is managed with a narrower maintainers-only gate.

`/assign-reviewers` adds every matched reviewer as a review request (the PR author is skipped).

## Configuration

These are **repository-level** settings under `repositories.<key>` in `config.yaml`.

### `minimum-lgtm`

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        minimum-lgtm: 1
```

Default `0` (no LGTM requirement). Type: integer. When greater than zero, the welcome comment
adds a "LGTM Count" line to the merge requirements.

How the count works in `check_if_can_be_merged` → `_check_if_pr_approved`:

- Eligible LGTM authors = matched PR reviewers + root approvers + root reviewers, **minus the
  PR committer**.
- `lgtm_count` counts `lgtm-<login>` labels whose login is in that eligible set. An `lgtm` from
  someone outside it is ignored.
- A PR **passes** if `lgtm_count >= minimum-lgtm`, **or** if `lgtm_count` equals the total number
  of eligible reviewers (i.e. everyone who could have LGTM'd already has). So a repo with two
  eligible reviewers and `minimum-lgtm: 2` is satisfied by two LGTMs even if one is below the
  threshold in other configurations, and `minimum-lgtm` larger than the reviewer count is never
  an impossible requirement.
- On failure the check-run reports:
  `Missing lgtm from reviewers. Minimum N required, (M given). Reviewers: a, b.`

### `can-be-merged-required-labels`

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        can-be-merged-required-labels:
          - signed-off
          - ci/green
```

Default `[]`. Type: array of strings. Every listed label must be present on the PR, otherwise
the `can-be-merged` check run fails with `Missing required labels: <label>`. Unlike OWNERS
approvals, these labels can be added by anyone who can label the PR.

### `auto-verified-and-merged-users`

```yaml
    # Global default, applies to all repositories
    auto-verified-and-merged-users:
      - release-bot

    repositories:
      my-repository:
        name: my-org/my-repository
        auto-verified-and-merged-users:
          - alice
```

Array of strings; the repository value overrides the global one. The logins of every configured
API token are appended automatically, so the bot accounts used by the webhook server are always
auto-verified.

If the PR committer is in this list:

- the `verified` label and a passing `verified` check run are applied automatically (on
  `opened`/`synchronize`, and reset when the PR is re-processed);
- the welcome comment carries an auto-verified note;
- no tracking issue is created for the PR;
- auto-merge is enabled for the PR regardless of the base branch — unless the PR touches
  `security-checks.suspicious-paths`, in which case auto-merge is blocked and any already-enabled
  auto-merge is disabled;
- `auto-verify-cherry-picked-prs: false` disables auto-verification for cherry-picked PRs even
  for these users.

`auto-verified-and-merged-users` bypasses the *verified* step only. The `can-be-merged` check —
approvers, `minimum-lgtm`, required labels, status checks — still applies.

## Merge decision summary

`check_if_can_be_merged` runs these and adds the `can-be-merged` label when all pass:

1. PR is mergeable (no conflicts).
2. No required status check or check run is in progress.
3. No `wip` / `hold` / `has-conflicts` labels (for enabled labels).
4. No failed or missing required status checks.
5. No `changes-requested-<login>` label from a matched approver, and all
   `can-be-merged-required-labels` present.
6. No unresolved conversation threads when `required_conversation_resolution` is enabled.
7. `_check_if_pr_approved`:
   - **Approvers**: for every matched `OWNERS` file, one of its `approvers` must be in the
     `approved-<login>` labels — except that an approval from any root approver clears the
     requirement for all files at once.
   - **LGTM**: the `minimum-lgtm` rule described above.

Failure output is written to the `can-be-merged` check run, e.g.
`Missing approved from approvers: alice, bob`.


---

Source: manage-pull-requests.md

# Manage Pull Requests

You want contributors to open a pull request and immediately understand what the server will do next: who gets assigned, which labels and checks appear, and what has to turn green before the PR is ready to merge. This page walks through the default PR lifecycle so authors and reviewers can work with the automation instead of guessing at it.

## Prerequisites

- The server is already connected to the repository. See [Start Automating a Repository](quick-start.html).
- An `OWNERS` file defines approvers and reviewers. See [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html).
- Any CI checks you expect on PRs are already configured. See [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html).

## Quick Example

```yaml
    # .github-webhook-server.yaml
    verified-job: true
    create-issue-for-new-pr: true
    minimum-lgtm: 1
    set-auto-merge-prs:
      - main
    can-be-merged-required-labels:
      - security-reviewed
```

```yaml
    # OWNERS
    approvers:
      - myakove
      - rnetser
    reviewers:
      - myakove
      - rnetser
```

Open a PR to `main`, then have an approver submit a GitHub review with state `Approved` and this review body:

```text
    /approve
```

With that setup, the server will:

- assign reviewers from `OWNERS`
- add a branch label such as `branch-main`
- add a size label such as `size/M`
- queue `can-be-merged` and `verified`, plus any enabled checks
- mark the PR merge-ready only after required checks, required labels, approval, and LGTM requirements are satisfied

## Step-by-Step

### 1. Open the PR or mark it ready for review

On `opened` and `ready_for_review`, the server posts a welcome comment that lists the available PR actions and the current merge requirements. It also assigns reviewers from the matching `OWNERS` rules, adds the PR author as an assignee when possible, and creates a tracking issue if `create-issue-for-new-pr: true`.

At the same time, the server applies the first round of state labels and queues check runs.

| What appears automatically | What it means |
| --- | --- |
| `branch-<base>` | Shows the target branch, such as `branch-main`. |
| `size/<bucket>` | Buckets the PR by total additions plus deletions, such as `size/M`. |
| `needs-rebase` | The PR is behind or diverged from its base branch. |
| `has-conflicts` | GitHub reports the PR is not mergeable. |
| `verified` | The PR is currently verified. This matters only when `verified-job` is enabled. |
| `can-be-merged` | The PR currently meets all merge requirements. |

> **Note:** `needs-rebase` is a badge, not the gate. `can-be-merged` decides merge readiness from GitHub's own state, so a PR can be behind base and still show `can-be-merged` when the base branch does not require up-to-date branches. When the base branch does require them, `can-be-merged` fails and the failure text names how many commits behind the PR is.

Any enabled built-in or custom checks are queued at the same time. Common examples are `tox`, `pre-commit`, `build-container`, `conventional-title`, and security checks.

### 2. Review the PR

GitHub review state and explicit approver sign-off are tracked separately. A normal GitHub `Approved` review counts as reviewer LGTM feedback, while the explicit approver signal is `/approve`.

| Reviewer action | What the server records | Where it matters |
| --- | --- | --- |
| GitHub review with state `Approved` | `lgtm-<reviewer>` | Counts toward `minimum-lgtm`. |
| Review body or PR comment containing `/approve` | `approved-<approver>` | Satisfies the approver requirement from `OWNERS`. |
| Required fixed label, such as `security-reviewed` | Exact label match | Satisfies `can-be-merged-required-labels`. |

> **Note:** The PR author does not count toward LGTM counting for their own PR.

### 3. Wait for merge readiness

The `can-be-merged` check is recalculated from the current head commit. It passes only when every active requirement is satisfied.

A PR becomes merge-ready when these checks all pass:

- required status checks are green
- the PR is mergeable and has no conflict label
- the PR is up to date with its base branch when the base branch requires up-to-date branches
- required approver sign-off is present
- the configured `minimum-lgtm` count is satisfied
- every label in `can-be-merged-required-labels` is present
- the `verified` state is present if `verified-job` is enabled
- unresolved review conversations are cleared if branch protection requires conversation resolution

### 4. Push more commits

On `synchronize`, the server rechecks merge state and reruns the PR workflow for the new head commit. Review-state labels are cleared so the new revision is evaluated fresh.

If the update is only a clean rebase, the server keeps verification aligned with the new head instead of forcing a full manual re-verify.

| Push result | What usually changes |
| --- | --- |
| New code commit | review-state labels are cleared, `verified` is re-queued, and merge readiness is recalculated |
| Clean rebase | merge state is refreshed, but existing verification can be carried forward to the new head |

### 5. Merge the PR

On merge, the server closes the tracking issue it created for the PR. If cherry-pick labels were already attached, the server starts those follow-up cherry-picks after merge.

It also refreshes merge-state labels on other open PRs so stale `needs-rebase` or conflict states get revisited after the branch moves forward. A PR that just became `needs-rebase` is re-checked for merge readiness immediately, so a `can-be-merged` that was green before the merge does not survive the base branch moving forward.

Adding or removing `needs-rebase` or `has-conflicts` also triggers a merge readiness recalculation. That keeps the labels and the check consistent even when a recheck could not run - for example when GitHub refused to read a commit - and it is what clears a stale green once the PR is rebased.

> **Tip:** If the PR targets a branch listed in `set-auto-merge-prs`, or if the author is in `auto-verified-and-merged-users`, the server can enable native GitHub auto-merge with squash merging as soon as the PR is initialized.

## Advanced Usage

Trusted authors and branch-based auto-merge change the PR lifecycle in important ways.

- `auto-verified-and-merged-users` makes PRs from those authors auto-verified on open and on later updates.
- Those same trusted authors can also get GitHub auto-merge enabled automatically.
- If `create-issue-for-new-pr` is enabled, trusted auto-verified authors skip tracking-issue creation.

Security-sensitive file paths can also override auto-merge behavior.

```yaml
    security-checks:
      mandatory: true
      suspicious-paths:
        - ".github/workflows/"
        - ".github/actions/"
      committer-identity-check: true
```

- If a PR touches a configured suspicious path, native auto-merge is not enabled until the suspicious-paths check passes.
- Security checks and committer-identity checks appear as normal check runs, so contributors can see the block directly on the PR.

Optional PR automation can fire on lifecycle events too.

```yaml
    test-oracle:
      server-url: "http://localhost:8000"
      ai-provider: "claude"
      ai-model: "claude-opus-4-6[1m]"
      test-patterns:
        - "tests/**/*.py"
      triggers:
        - approved
        - pr-opened
```

- `pr-opened` starts test recommendation analysis when the PR is first opened.
- `approved` starts it when an approver explicitly uses `/approve`.
- See [Supported GitHub Events](supported-github-events.html) for the full event matrix.
- See [Configuration Reference](configuration-reference.html) for the exact PR-related keys and defaults.

## Troubleshooting

**`can-be-merged` is failing**

- Open the `can-be-merged` check first. It reports the missing requirement directly, such as missing approver sign-off, missing required labels, unresolved conversations, failing checks, merge conflicts, or a PR that is behind its base branch.

**`can-be-merged` is green but GitHub still blocks the merge button**

- Open the check and read its output. When the base branch requires up-to-date branches, a green `can-be-merged` already accounts for that, so a blocked button usually means a different required check or a review requirement is still missing. `needs-rebase` is only a badge and does not gate the merge on its own.

**The PR lost `verified` after a push**

- That is normal for a new code revision when `verified-job` is enabled. The server re-queues verification on new commits and only preserves it automatically for clean rebases or trusted auto-verified authors.

**A normal GitHub approval did not satisfy the approver requirement**

- Use `/approve` from an approver. In this workflow, GitHub review approval and approver sign-off are separate signals.

**Auto-merge did not turn on**

- Check whether the base branch is listed in `set-auto-merge-prs`, whether the author is in `auto-verified-and-merged-users`, and whether the PR touches a suspicious path that keeps auto-merge off until security checks pass.

## Related Pages

- [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html)
- [Run Pull Request Commands](run-pull-request-commands.html)
- [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html)
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html)
- [Supported GitHub Events](supported-github-events.html)


---

Source: run-pull-request-commands.md

# Run Pull Request Commands

The webhook server reacts to slash commands posted as pull request comments. Comment `/wip` on a PR and the server
adds the `wip` label and prefixes the title with `WIP:`.

## How commands are recognized

- Only comments **created** on a PR are processed. Editing or deleting a comment does nothing.
- Every **line** of the comment that starts with `/` is treated as a command. The leading `/` is stripped, the first
  word is the command, and the rest is the argument.
- Several commands can be posted in a single comment — each line is one command, and they run concurrently.
- A command argument of exactly `cancel` turns the command into a removal (`/wip cancel`).
- The server reacts with a 👍 to the comment once a command is accepted.
- Comments containing the server's own welcome-message URL are ignored, so the bot never processes its own output.
- Unknown commands are silently ignored (logged only).

Example — one comment, three commands:

```text
    /wip
    /assign-reviewers
    /retest tox
```

## Who can run what

| Who | Meaning |
| --- | --- |
| **Author** | The user who opened the pull request |
| **Approver** | A repository collaborator/contributor, an approver listed in `OWNERS`, a maintainer, **or** anyone a maintainer/approver has granted with `/add-allowed-user @user` |
| **Maintainer** | Listed in the `root-approvers` configuration, or otherwise resolved as a repository maintainer |
| **Anyone** | Any user who can comment, no permission check |

| Command | What it does | Who can run it |
| --- | --- | --- |
| `/wip` | Adds the `wip` label and prefixes the title with `WIP: ` | Author, Approver |
| `/wip cancel` | Removes the `wip` label and strips the `WIP:` prefix from the title | Author, Approver |
| `/verified` | Adds the `verified` label and sets the `verified` check run to success | Author, Approver |
| `/verified cancel` | Removes the `verified` label and re-queues the `verified` check run | Author, Approver |
| `/hold` | Adds the `hold` label, blocking the merge | Author, Approver |
| `/hold cancel` | Removes the `hold` label | Author, Approver |
| `/lgtm` | Adds the `lgtm-<user>` label and removes that user's `changes-requested-<user>` | Anyone (ignored on your own PR) |
| `/lgtm cancel` | Removes the `lgtm-<user>` label | Anyone (ignored on your own PR) |
| `/approve` | Adds `approved-<user>`, removes `changes-requested-<user>`, and triggers the AI test oracle | Approver |
| `/approve cancel` | Removes `approved-<user>` | Approver |
| `/automerge` | Adds the `automerge` label; the PR merges automatically once all requirements are met | Maintainer, Approver |
| `/automerge cancel` | Removes the `automerge` label | Maintainer, Approver |
| `/retest <test>` | Re-runs one or more configured checks | Approver |
| `/retest all` | Re-runs every check configured for the repository | Approver |
| `/reprocess` | Re-runs the complete PR processing workflow from scratch | Approver |
| `/rebase` | Rebases the PR branch onto its base branch and force-pushes | Approver |
| `/assign-reviewers` | Assigns reviewers according to the repository `OWNERS` file | Approver |
| `/assign-reviewer @user` | Requests a review from a specific collaborator | Approver |
| `/add-allowed-user @user` | Grants an arbitrary commenter permission to run approver commands | Maintainer, Approver |
| `/check-can-merge` | Runs the `can-be-merged` check and reports the merge blockers | Approver |
| `/build-and-push-container` | Builds and pushes the container image for the PR | Anyone |
| `/cherry-pick <branch> [branch...]` | Cherry-picks the PR to one or more target branches | Approver |
| `/cherry-pick-retry <branch>` | Retries a failed cherry-pick on a merged PR | Approver |
| `/regenerate-welcome` | Re-renders the welcome comment with the current configuration | Approver |
| `/test-oracle` | Sends the PR to the configured PR Test Oracle service | Approver |
| `/security-override` | Sets the security check runs to success | Maintainer |
| `/security-override cancel` | Re-runs the security checks to re-evaluate them | Maintainer |

## Commands on draft pull requests

Draft PRs are blocked by default. The `allow-commands-on-draft-prs` configuration key controls the exception list.
Set it in `config.yaml`, either globally or under `repositories.<repo>` to override the global default for a single
repository:

```yaml
    # config.yaml
    repositories:
      owner/repo-name:
        allow-commands-on-draft-prs:
          - build-and-push-container
          - retest
```

This key is read from `config.yaml` only. Unlike most other settings, it is **not** read from a repository-local
`.github-webhook-server.yaml` — placing it there has no effect and draft PRs stay blocked.

| Value | Behaviour |
| --- | --- |
| Not set (default) | All commands are blocked on draft PRs |
| `[]` (empty list) | All commands are allowed on draft PRs |
| `["retest", "wip"]` | Only the listed commands are allowed; any other command is rejected with a comment naming the allowed ones |

`/test-oracle` is the one exception to the per-command allowlist — it runs on a draft PR even when it is not listed in
`allow-commands-on-draft-prs`. That exception only applies when the key is set, even to an empty list. If the key is
absent, the draft pull request is dropped before any command is evaluated, so `/test-oracle` does not run either.

## Command details

### PR status

#### `/wip` and `/wip cancel`

Marks the PR as work in progress. `/wip` adds the `wip` label and prepends `WIP: ` to the title if the prefix is not
already there. `/wip cancel` removes the label and strips the prefix (both `WIP:` and `WIP: ` are handled).

```text
    /wip
```

```text
    /wip cancel
```

Available to the PR author and approvers. A user who is neither is ignored.

#### `/verified` and `/verified cancel`

Marks the change as reviewed and verified. `/verified` also sets the `verified` check run to **success**;
`/verified cancel` removes the label and puts the `verified` check run back to **queued** so it runs again.

```text
    /verified
```

```text
    /verified cancel
```

Available to the PR author and approvers.

#### `/hold` and `/hold cancel`

`hold` blocks the pull request from merging until it is removed. Because it is merge-blocking, it is narrower than
`/wip` and `/verified`: only the PR author and pull request approvers can set or clear it. Anyone else gets a comment
explaining that only the author or approvers may manage the label.

```text
    /hold
```

```text
    /hold cancel
```

### Review and approval

#### `/lgtm` and `/lgtm cancel`

Records "looks good to me" for the commenting user by adding the `lgtm-<user>` label and removing that user's
`changes-requested-<user>` label. Any commenter may use it; if the commenter is the PR author, the command is ignored
so you cannot lgtm your own pull request.

```text
    /lgtm
```

```text
    /lgtm cancel
```

#### `/approve` and `/approve cancel`

Records a formal approval by adding `approved-<user>` and removing `changes-requested-<user>`. Only users in the
`OWNERS` approver list (or root approvers) are accepted; a non-approver's `/approve` is silently ignored.

A successful `/approve` also kicks off the PR Test Oracle in the background, if it is configured.

```text
    /approve
```

```text
    /approve cancel
```

#### `/automerge` and `/automerge cancel`

`/automerge` adds the `automerge` label. Once the label is present and every merge requirement is satisfied, the server
merges the PR on its own. `/automerge cancel` removes the label.

```text
    /automerge
```

```text
    /automerge cancel
```

Both are restricted to maintainers and approvers; anyone else gets the comment
*"Only maintainers or approvers can set pull request to auto-merge"*.

#### `/assign-reviewers`

Assigns reviewers to the pull request based on the `OWNERS` file in the repository.

```text
    /assign-reviewers
```

#### `/assign-reviewer @username`

Requests a review from one specific user. The target must be a repository collaborator, otherwise the server comments
that the user is not a collaborator.

```text
    /assign-reviewer @octocat
```

#### `/check-can-merge`

Evaluates the merge requirements and reports which ones are still blocking the merge.

```text
    /check-can-merge
```

#### `/add-allowed-user @username`

Lets a maintainer or approver grant command permission to someone who is not otherwise a collaborator — for example an
external contributor working on a PR. The grant is per pull request: the server looks for a comment containing
`/add-allowed-user @username` posted by a maintainer or approver.

```text
    /add-allowed-user @external-contributor
```

The user can then run approver-level commands such as `/retest` or `/reprocess` on that PR.

### Testing and validation

#### `/retest <test> [test...]` and `/retest all`

Re-runs one or more checks. The test name is required — `/retest` with no argument gets a
*"retest requires an argument"* comment. Unknown names get a *"No <name> configured for this repository"* comment; the
valid ones still run.

Supported names depend on the repository configuration:

| Test name | Runs |
| --- | --- |
| `tox` | The Python test suite |
| `build-container` | Rebuilds and tests the container image |
| `python-module-install` | Tests installing the Python package |
| `pre-commit` | Runs pre-commit hooks and checks |
| `conventional-title` | Validates the commit message format |
| *custom check-run names* | Any check run defined in `custom-check-runs` |

```text
    /retest tox
```

```text
    /retest tox pre-commit
```

```text
    /retest all
```

`all` cannot be combined with other names — `/retest all tox` is rejected with
*"Invalid command. `all` cannot be used with other tests"*.

#### `/build-and-push-container`

Builds the container image for the PR and pushes it, tagged with the PR number. Extra build arguments are supported:

```text
    /build-and-push-container
```

```text
    /build-and-push-container --build-arg KEY=value
```

If the repository has no container build configured, the server replies
*"No build-and-push-container configured for this repository"*. No permission check is applied.

#### `/test-oracle`

Sends the pull request data to the external PR Test Oracle service, which analyses the diff and recommends tests.
Requires `test-oracle` to be configured with a `server-url`. Runs in the background, so the command returns
immediately.

```text
    /test-oracle
```

This command is also the only one that bypasses the draft-PR block.

### Branch management and cherry-picks

#### `/rebase`

Rebases the PR head branch onto its base branch and force-pushes the result.

```text
    /rebase
```

Restrictions enforced by the server:

- The PR must be open.
- Fork PRs are rejected — the head branch lives in another repository, so the force-push cannot be scoped to this repo.
- For bot-owned PRs (such as cherry-pick PRs) the requester must be the cherry-pick initiator (the PR assignee) or a
  maintainer.

#### `/cherry-pick <branch> [branch...]`

Cherry-picks the change to one or more target branches. Each branch that exists gets a `cherry-pick-<branch>` label.

```text
    /cherry-pick v1.0
```

```text
    /cherry-pick v1.0 v2.0
```

Behaviour depends on the PR state:

- **Unmerged PR** — the labels are added as a to-do list. When the PR is merged, the server sees them and runs the
  cherry-picks.
- **Merged PR** — the cherry-picks run immediately, one per target branch, and the labels act as an audit record of
  what was back-ported.

Branches that do not exist are reported in a comment and skipped. If a `cherry-pick-<branch>` label is already
present, that branch is skipped with a note to remove the label and run the command again to re-trigger it.

#### `/cherry-pick-retry <branch>`

Retries a cherry-pick that failed. Exactly one branch name is accepted. The command validates that:

1. the PR is merged,
2. the `cherry-pick-<branch>` label already exists (otherwise it points you at `/cherry-pick`),
3. any existing cherry-pick PR created by the bot for that branch is closed, and

then re-runs the cherry-pick.

```text
    /cherry-pick-retry v1.0
```

### Workflow control

#### `/reprocess`

Re-runs the complete pull request processing workflow from scratch: labels, checks, reviewers, and so on. Merged PRs
are skipped.

```text
    /reprocess
```

#### `/regenerate-welcome`

Re-renders the welcome comment using the current server and repository configuration, editing the existing welcome
comment if there is one and creating it if there is not. Useful after changing the configuration — the welcome message
documents exactly which commands are active for the repository.

```text
    /regenerate-welcome
```

### Security

#### `/security-override` and `/security-override cancel`

When the security checks (`security-suspicious-paths`, `security-committer-identity`) fail, a maintainer can:

- `/security-override` — set the security check runs to **success**, annotated with the maintainer's name and a
  reminder to use `cancel` to undo it.
- `/security-override cancel` — re-run both security checks so they are evaluated again from scratch.

```text
    /security-override
```

```text
    /security-override cancel
```

Only maintainers may use these; anyone else gets *"Only maintainers can use `/security-override`"*. If neither security
check is enabled for the repository, the server replies *"No security checks are enabled — nothing to override."*

### Generic label commands

In addition to the commands above, the server's user-facing labels can be set and cleared directly:

```text
    /verified
    /verified cancel
```

Adding `cancel` removes the label instead of adding it. This is the same mechanism that powers `/wip`, `/hold`,
`/verified`, `/lgtm`, `/approve`, and `/automerge` — their permission rules are described in the tables above. Any
other label name, including `needs-rebase`, `has-conflicts`, `can-be-merged`, and `size/*`, is not accepted as a
command; those labels are managed by the server itself.


---

Source: set-up-checks-and-release-workflows.md

# Set Up Checks and Release Workflows

Checks are the pull request gates this server runs itself. It does not call GitHub Actions for tests: it clones the PR, runs commands in a worktree, and writes the results back as GitHub check runs. Release workflows are the same machinery, but triggered by a tag push — a container image push and a PyPI publish.

This page covers the two halves: what checks exist and how required-versus-advisory is decided, then how the container and PyPI release blocks work.

## Prerequisites

- A running webhook server with at least one repository configured. See [Start Automating a Repository](quick-start.html) and [Configure Repositories](configure-repositories.html).
- Python `3.14`, `uv`, and `podman` on the server host — the built-in checks shell out to `uvx` and `podman`.
- A container registry account if you want `build-container`; a PyPI API token if you want PyPI publishing.
- Manage-repositories app access: branch protection, labels, and security settings are written at startup with the highest-rate-limit token, not with the webhook's app token.

## Part 1 — Checks

### How a check run gets created and reported

Every check follows the same lifecycle, implemented in `webhook_server/libs/handlers/runner_handler.py`:

1. `set_check_queued` when the PR event is received (only for checks whose feature is enabled).
2. `set_check_in_progress` when the runner starts.
3. A git worktree is created from the shared clone and the base branch is merged into it.
4. The command runs, stdout/stderr are stripped of ANSI codes and redacted, then posted to the check run output.
5. `set_check_success` or `set_check_failure`.

All check runs are written to `last_commit.sha`. Output is truncated to 65534 characters to stay under GitHub's limit, keeping the head and the tail, and the full redacted output stays in the server log. Secrets from `pypi.token`, the container registry username and password, and the GitHub token are replaced with `*****` before anything is posted.

Built-in and custom command checks share one code path, `RunnerHandler.run_check()`. Each one is described by a small `CheckConfig` record: the check-run name, the command (which may contain the `{worktree_path}` placeholder), a display title, and whether the command runs with `cwd` set to the worktree.

The CI stage is driven by `PullRequestHandler._run_pull_request_ci_tasks()`. It always schedules tox, pre-commit, python-module-install, and build-container; each of those runners returns early if its feature is not configured. `conventional-title` is scheduled only when its config is present. The two security checks need no opt-in: with no `security-checks` block at all, `suspicious-paths` falls back to `DEFAULT_SUSPICIOUS_PATHS` and both `mandatory` and `committer-identity-check` default to `true`, so both run. Every validated custom check is scheduled unconditionally.

### The built-in checks

Check-run names are constants in `webhook_server/utils/constants.py`, so what the GitHub UI shows is exactly these strings.

| Check run | Config key | Notes |
| --- | --- | --- |
| `tox` | `tox` | Runs `uvx tox --workdir {worktree_path} --root {worktree_path} -c {worktree_path}`. |
| `pre-commit` | `pre-commit` | Runs `uvx --directory {worktree_path} prek run --all-files`. |
| `build-container` | `container` | `podman build` of the repo Dockerfile. Also used for release pushes with no check run. |
| `python-module-install` | `pypi` | `uvx pip wheel --no-cache-dir -w {worktree_path}/dist {worktree_path}`. |
| `conventional-title` | `conventional-title` | Validates the PR title against Conventional Commits v1.0.0. |
| `can-be-merged` | always on | The rollup verdict. Never counted as a required check by the server. |
| `security-suspicious-paths` | `security-checks.suspicious-paths` | Fails when a changed file is under a suspicious path prefix. |
| `security-committer-identity` | `security-checks.committer-identity-check` | Fails when the last committer is not the PR author and not trusted. |
| `verified` | `verified-job` (default `true`) | Human-driven: a check run named after the `verified` label, set to success when the label is added. |

Notes per check:

- **`tox`** is a mapping from base branch name to either a string or a list of tox environments. Empty or `all` means every environment. `tox.args` is appended verbatim to the command; `tox.python-version` adds `--python=<version>` to the `uvx` invocation. `args` and `python-version` are nested keys of the `tox` block; `tox-python-version` still works as a deprecated top-level fallback. The selected value has its spaces stripped and is appended as a single `-e <value>` argument.

- **`pre-commit`** is a boolean. The schema documents `default: true`, but the server treats an unset `pre-commit` as **false** — set it explicitly to turn the check on. Note this check is required through branch protection (see below), not through the server's own required list.

- **`verified`** is not a command check. It is queued on PR events and set to success when the `verified` label is added, and back to queued when the label is removed. Because `verified-job` defaults to true, the string `verified` is in the server's required list unless you set `verified-job: false`.

- **`can-be-merged`** is the aggregate. `PullRequestHandler.check_if_can_be_merged()` sets it in progress, then accumulates failure reasons: GitHub mergeable state, required checks still in progress, required checks failed or not started, `wip`/`hold` labels, `can-be-merged-required-labels`, unresolved review threads when `branch-protection.required_conversation_resolution` is on, and approval/verification requirements. Empty failure output means success and the `can-be-merged` label is added. It is explicitly skipped when the server evaluates required checks, so it can never deadlock itself.

- **`security-suspicious-paths`** compares `owners_file_handler.changed_files` against the configured prefixes. Default prefixes (`DEFAULT_SUSPICIOUS_PATHS`): `.claude/`, `.vscode/`, `.cursor/`, `.devcontainer/`, `.pi/`, `.github/workflows/`, `.github/actions/`. An empty list disables the check entirely.

- **`security-committer-identity`** compares the PR author (parent committer) with the last commit's committer. An unresolvable committer always fails. A mismatch fails unless the committer is in the trusted list, which is `security-checks.trusted-committers` plus the GitHub App bot, `web-flow`, and API users. A `web-flow` login with the wrong immutable user ID fails as a suspected impersonation. Maintainers can clear a security failure with `/security-override`, which forces the check runs to success.

### How required versus advisory is decided

There are two separate lists, and they are not the same list.

**1. The runtime required list** — `CheckRunHandler.all_required_status_checks()`. This is what the server consults on every webhook to decide whether `can-be-merged` should fail. It is assembled, in order, from:

- branch protection required status check contexts read from the PR's base branch (`get_branch_required_status_checks()`),
- `tox` if configured,
- `verified` if `verified-job` is on,
- `build-container` if `container` is configured,
- `python-module-install` if `pypi` is configured,
- `conventional-title` if configured,
- every custom check whose `mandatory` is `true`,
- the security checks, when `security-checks.mandatory` is true (default): `security-suspicious-paths` if a suspicious-paths list is configured, and `security-committer-identity` if `committer-identity-check` is on.

The result is deduplicated while preserving order and cached for the lifetime of the handler instance. Note that `pre-commit`, `can-be-merged`, and non-mandatory custom checks are **not** in this list — they never block `can-be-merged` through the server's own logic. Blocking them is a GitHub branch-protection job, covered next.

Branch protection contexts are only read for public repositories; for private repositories `get_branch_required_status_checks()` returns an empty list and the server relies solely on its config-derived list.

**2. GitHub branch protection** — applied at server startup, not per webhook. `repository_and_webhook_settings()` runs on boot and calls `set_repositories_settings()`, which for each repository calls `set_repository()`, which for each branch under `protected-branches` calls `set_branch_protection()`. This is the only place branch protection is written. Nothing in the webhook path re-derives or re-applies it, so a change to `protected-branches` takes effect on the next server start, not on the next PR.

`set_repository()` also creates the static labels with their configured colors, enables `delete_branch_on_merge`, `allow_auto_merge`, and `allow_update_branch`, and — for public repositories only — enables secret scanning and secret scanning push protection. Private repositories skip branch protection and security settings entirely.

The required check list written to a branch is:

- the repository's `default-status-checks` list, always plus `can-be-merged`, taken from a deep copy so per-branch exclusions cannot mutate the shared list;
- then, depending on `protected-branches.<branch>`:

**When `include-runs` is non-empty**, it *is* the branch's required-check list. Nothing is derived from config — no `tox`, no `container`, no `default-status-checks`, no `pre-commit`. The only thing appended is the security checks, again gated on `security-checks.mandatory`, deduplicated while keeping `include-runs` order. `exclude-runs` is then subtracted from the assembled list, so the two are a filter pair on this path too.

**When `include-runs` is empty or absent**, the list is derived by `get_required_status_checks()`: `tox` if configured, `verified` if `verified-job` is not false, `build-container` if `container` is configured, `python-module-install` if `pypi` is configured, `pre-commit` if `pre-commit` is true, `conventional-title` if configured, and `pre-commit.ci - pr` if `.pre-commit-config.yaml` exists in the repository. Then the security checks are appended under the same `mandatory` gate, the result is deduplicated, and finally `exclude-runs` entries are removed.

`exclude-runs` applies to both paths: it filters the automatically derived list *and* the explicit `include-runs` list, and it wins — an explicit `exclude-runs` entry is honoured for any check, including the appended security checks. Removing a security check this way is therefore possible, but if you do not want the security checks required at all, the switch is `security-checks.mandatory: false`.

Branch protection rules themselves come from `branch-protection` (global or per-repository), merged over `DEFAULT_BRANCH_PROTECTION`:

```yaml
    strict: true
    require_code_owner_reviews: false
    dismiss_stale_reviews: true
    required_approving_review_count: 0
    required_linear_history: true
    required_conversation_resolution: true
```

The API user that writes the settings is added to the users, teams, and apps bypass lists so the server can still operate on protected branches.

`protected-branches` accepts either a plain list or a mapping:

```yaml
    repositories:
      your-repo:
        name: your-org/your-repo
        default-status-checks:
          - lint
        protected-branches:
          # Derived from config, minus `build-container`
          main:
            exclude-runs:
              - build-container
          # Explicit list — only security checks are added
          release/2.0:
            include-runs:
              - can-be-merged
              - tox
              - verified
```

A branch value given as a bare list is accepted by the schema, but it carries no `include-runs`/`exclude-runs`, so it takes the derived path.

### Custom check runs

`custom-check-runs` adds your own commands to the same lifecycle as the built-ins. `GithubWebhook._validate_custom_check_runs()` filters the list at load time; invalid entries are dropped with a warning, and only validated checks run.

```yaml
    repositories:
      your-repo:
        name: your-org/your-repo
        custom-check-runs:
          - name: lint
            command: uv tool run --from ruff ruff check
            mandatory: true
          - name: security-scan
            command: BANDIT_CONFIG=ci uv tool run --from bandit bandit -r .
            mandatory: false
          - name: unit
            command: |
              uv run pytest tests -q
```

Validation rules, all enforced at startup:

- `name` is required and must be a string.
- `name` must match `^[a-zA-Z0-9._-]{1,64}$`.
- `name` must **not** be in `BUILTIN_CHECK_NAMES` — a collision is logged and the entry skipped. `BUILTIN_CHECK_NAMES` is exactly `tox`, `pre-commit`, `build-container`, `python-module-install`, `conventional-title`, `can-be-merged`, `security-suspicious-paths`, `security-committer-identity`. (`verified` is a check run but is not in that set, so a custom check may take that name.)
- `name` must be unique within the repository's list.
- `command` is required, must be a non-empty string after stripping, and must parse with `shlex`.
- The first token that is not a `VAR=value` environment assignment must resolve with `shutil.which()` on the server. If the executable is not installed, the check is skipped with a warning rather than failing later at run time.

Custom commands run through `/bin/sh -c` with the worktree as the working directory, so pipes, subshells, and inline environment variables work. `{worktree_path}` is not needed for custom checks, though it is substituted if present. The check run title is `Custom Check: <name>`.

`mandatory` defaults to `true`. A mandatory custom check is added to `all_required_status_checks()` and therefore blocks `can-be-merged`; a non-mandatory one still runs and is still retestable, it just never blocks.

### Retesting

`/retest <check>` re-runs a single check, and `/retest all` re-runs everything available. The allowed names come from `GithubWebhook._current_pull_request_supported_retest`: `tox` (if configured), `build-container` (if configured), `python-module-install` (if configured), `pre-commit` (if configured), `conventional-title` (if configured), every custom check name — mandatory or not — and both security checks when they are enabled. `can-be-merged` and `verified` are not retestable. An unknown name is logged and skipped. The welcome comment lists the supported retests for the repository.

On startup, `set_all_in_progress_check_runs_to_queued()` walks open pull requests and resets any check run in `BUILTIN_CHECK_NAMES` that is stuck in `in_progress` back to `queued`, so a server restart during a run does not leave a gate hanging. Custom checks and `verified` are not covered by that sweep.

### Related repository keys

- `set-auto-merge-prs` — list of base branches where GitHub auto-merge is enabled. Auto-merge is blocked when the PR touches a suspicious path, and an already-enabled auto-merge is disabled; a maintainer `/security-override` re-enables it. Cherry-picks whose conflicts were resolved by AI are never auto-merged.
- `create-issue-for-new-pr` — boolean, default `true`. Creates a tracking issue per PR. Set it globally and per repository; the repository value wins.
- `cherry-pick-assign-to-pr-author` — boolean, default `true`. Assigns cherry-pick PRs to the author of the original PR. Also global-with-repository-override.

## Part 2 — Release workflows

Both release paths run on a **tag push**. `PushHandler.process_push_webhook_data()` matches `refs/tags/(.+)`; if the ref is a branch, nothing release-related happens. When it is a tag, PyPI publishing runs first, then the container release.

### Container builds and pushes

Enable the `container` block. A check run named `build-container` appears on every PR.

```yaml
    repositories:
      your-repo:
        name: your-org/your-repo
        container:
          username: your-registry-user
          password: your-registry-token
          repository: registry.example.com/your-org/your-repo
          dockerfile: Dockerfile
          context: ""
          tag: latest
          release: true
          build-args:
            - PYTHON_VERSION=3.14
          args:
            - --log-level=debug
          oci-annotations:
            enabled: true
            static:
              org.opencontainers.image.vendor: your-org
            auto:
              created: true
              source: true
              revision: true
              version: true
              title: true
```

Keys, exactly as the server reads them:

| Key | Default | Effect |
| --- | --- | --- |
| `username`, `password` | required | Registry credentials; used for `podman push --creds` and redacted from output. |
| `repository` | required | Image repository, without a tag. |
| `dockerfile` | `Dockerfile` | Dockerfile path relative to the worktree. |
| `context` | `""` (repo root) | Build context subdirectory. Schema restricts it to `[a-zA-Z0-9._/-]*`. |
| `tag` | `latest` | Tag for post-merge builds on the main branches. |
| `release` | `false` | Build and push an image when a tag is pushed. |
| `build-args` | `[]` | Each entry becomes a `--build-arg` flag. |
| `args` | `[]` | Extra flags prepended to the `podman build` command. |
| `oci-annotations` | disabled | See below. |

`build-args` and `args` also accept a single string, which is split with `shlex`; anything that is neither a string nor a list is ignored. `username`, `password`, and `repository` are read as required keys — a `container` block missing any of them raises on load, so it is all-or-nothing. `dockerfile` is read by the server even though the schema does not list it, so set it there and it will be honoured.

Tag selection, from `container_repository_and_tag()`:

- On a PR: `pr-<number>`.
- After merge: the base branch name, unless the base branch is `main` or `master`, in which case the configured `tag` (`latest` by default).
- On a release tag push: the tag name, as pushed.

The build context is resolved with `os.path.realpath` and rejected with a failed check run if it escapes the worktree — `context` cannot be used to reach files outside the repository. Merged and release builds add `--no-cache`; PR builds do not. `podman build` runs with `--network=host`. A known podman reboot-cache bug is detected and retried after clearing the stale storage directory.

Pushing happens only for a successful build, using `podman push --creds <user>:<password> <repo>:<tag>`. On success the PR gets a comment and, if `slack-webhook-url` is set, a Slack message; on failure a comment says the push failed. The `/build-and-push-container` comment command triggers the same path on demand and is gated on the commenter's permission to run commands.

#### OCI annotations

`oci-annotations.enabled` defaults to `false`; nothing is added when it is off. When on, each annotation becomes a `--annotation key=value` flag on `podman build`. The `auto` block populates annotations from webhook context, and every auto entry defaults to `true` when `oci-annotations` is enabled:

- `org.opencontainers.image.created` — build timestamp, UTC, `YYYY-MM-DDTHH:MM:SSZ`
- `org.opencontainers.image.source` — `https://github.com/<repo>`
- `org.opencontainers.image.revision` — PR head SHA, or the pushed commit for a tag build
- `org.opencontainers.image.version` — the image tag, only when there is one
- `org.opencontainers.image.title` — the repository name

`static` entries are free-form key-value pairs; use reverse-domain notation. Static annotations are applied last and override auto-populated ones with the same key.

### PyPI publishing

The `pypi` block is both a PR check and a release job.

```yaml
    repositories:
      your-repo:
        name: your-org/your-repo
        pypi:
          token: pypi-AgEIcHlwaS5vcmc...
```

On pull requests, the `python-module-install` check runs `uvx pip wheel --no-cache-dir -w {worktree_path}/dist {worktree_path}` — the package must build and its dependencies must resolve.

On a tag push, `PushHandler.upload_to_pypi()`:

1. Checks out the tag into a worktree.
2. Runs `uv build --sdist --out-dir <worktree>/pypi-dist`.
3. Lists the dist directory and takes the resulting `.tar.gz` filename.
4. Runs `twine check` on the sdist.
5. Runs `twine upload --username __token__ --password <token> <sdist> --skip-existing`.

The token is redacted from all captured output. Any failure — checkout, build, listing, check, or upload — opens a GitHub issue titled with the first line of the error, body `Publish to PYPI failed: ...`, and stops the tag handling (the container release is skipped for that push). On success a Slack message is sent when `slack-webhook-url` is set. `--skip-existing` means re-pushing an existing version succeeds without republishing.

## Verify it worked

After restarting the server with the new configuration:

- The startup log lists each repository's branches and the checks it enabled, for example `Set branch main setting for your-org/your-repo. enabled checks: ['can-be-merged', 'tox', 'pre-commit', 'verified']`. Compare that against what you expect.
- `Loaded N custom check(s): [...]` and `Skipped N invalid custom check(s)` appear if you configured custom checks; resolve any skipped names.
- In GitHub, open the protected branch's settings and confirm the required status checks match your `include-runs`/`exclude-runs` intent.
- Open a test PR and confirm the expected check runs appear as queued and then complete, and that `can-be-merged` fails with a readable reason if something is still running.

## Related pages

- [Configuration Reference](configuration-reference.html) — every key in `config.yaml` and repository-local overrides
- [Configure Repositories](configure-repositories.html) — rollout patterns across repositories
- [Run Pull Request Commands](run-pull-request-commands.html) — `/retest`, `/security-override`, `/build-and-push-container`
- [Manage Pull Requests](manage-pull-requests.html) — labels, reviewers, and the merge flow
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) — the security checks in depth


---

Source: enable-ai-features.md

# Enable AI Features

Turn on AI help when you want pull requests to spend less maintainer time on bad titles, conflicted cherry-picks, and test selection. With the right settings, the server can suggest or fix PR titles, try to resolve cherry-pick conflicts, and ask a separate oracle service for test recommendations.

## Prerequisites

- A running `github-webhook-server` deployment
- Access to `config.yaml` or a repository-local `.github-webhook-server.yaml`
- The provided container image, or another runtime that includes the bundled AI sidecar and supported AI CLIs
- One supported provider credential for local AI features: `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `CURSOR_API_KEY`
- A reachable `pr-test-oracle` service if you want test recommendations

## Quick Example

```yaml
    # config.yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,revert"
        ai-features:
          ai-provider: "claude"
          ai-model: "sonnet"
          conventional-title:
            enabled: true
            mode: suggest
          resolve-cherry-pick-conflicts-with-ai:
            enabled: true
        test-oracle:
          server-url: "http://localhost:8000"
          ai-provider: "claude"
          ai-model: "sonnet"
          triggers:
            - approved
```

```yaml
    # docker-compose.yaml
    services:
      github-webhook-server:
        environment:
          - ANTHROPIC_API_KEY=sk-ant-xxx
```

Save the config, then restart only if you changed environment variables or mounts. On the next PR event, the title check can show an AI suggestion, the next conflicted cherry-pick can attempt AI resolution, and users allowed to run comment commands can trigger `/test-oracle` on demand.

## Step-by-Step

1. Put shared defaults and repo rules in the right place

```yaml
    ai-features:
      ai-provider: "claude"
      ai-model: "sonnet"

    test-oracle:
      server-url: "http://localhost:8000"
      ai-provider: "claude"
      ai-model: "sonnet"

    repositories:
      my-repository:
        name: my-org/my-repository
        conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,revert"
```

Use top-level `ai-features` and `test-oracle` when several repos should share the same defaults. Keep `conventional-title` on each repo that should enforce it, and use repo-level or `.github-webhook-server.yaml` values when one repo needs different AI behavior.

Both `ai-features` and `test-oracle` need `ai-provider` and `ai-model`. `test-oracle` also needs `server-url`.

See [Configuration Reference](configuration-reference.html) for every supported key.

2. Add provider credentials and check that the sidecar is healthy

```yaml
    services:
      github-webhook-server:
        environment:
          - ANTHROPIC_API_KEY=sk-ant-xxx
          # - GEMINI_API_KEY=xxx
          # - CURSOR_API_KEY=xxx
          # - ACPX_AGENTS=cursor
```

```bash
    curl -f http://localhost:5000/webhook_server/healthcheck
    curl -f http://localhost:9100/health
```

Use the credential that matches your `ai-provider` value. The first command checks the webhook server, and the second checks the local AI sidecar; replace `9100` if you set `SIDECAR_PORT`.

| Provider | What to add |
| --- | --- |
| `claude` | `ANTHROPIC_API_KEY` |
| `gemini` | `GEMINI_API_KEY` |
| `cursor` | `CURSOR_API_KEY` |

> **Tip:** Add `ACPX_AGENTS=cursor` when you want Cursor model discovery. The example deployment also supports `docker exec -it github-webhook-server agent` if you prefer Cursor interactive login instead of `CURSOR_API_KEY`.

See [Environment Variables](environment-variables.html) for the full sidecar and credential list, and [Webhook and Health API](webhook-and-health-api.html) for the main server health endpoint.

3. Enable conventional title suggestions or auto-fixes

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,revert"
        ai-features:
          ai-provider: "claude"
          ai-model: "sonnet"
          conventional-title:
            enabled: true
            mode: suggest
            timeout-minutes: 10
```

| Mode | Result |
| --- | --- |
| `suggest` | A failed `conventional-title` check includes an `AI-Suggested Title` block. |
| `fix` | The server edits the PR title and turns the check green if the suggestion validates. |

Open a PR with a non-matching title, or retitle an existing PR, to test the feature. If the repo does not have a `conventional-title` rule, this AI feature stays idle even if `ai-features` is present.

> **Tip:** Start with `suggest` so humans can review the wording before you move to `fix`.

4. Enable AI cherry-pick conflict resolution

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        ai-features:
          ai-provider: "claude"
          ai-model: "sonnet"
          resolve-cherry-pick-conflicts-with-ai:
            enabled: true
            timeout-minutes: 10
```

When the server performs a cherry-pick and Git reports a real `CONFLICT`, it can ask the sidecar to resolve the files and finish the cherry-pick automatically. If that succeeds, the new PR gets the `ai-resolved-conflicts` label and both PRs receive comments telling the assignee to verify the result manually.

> **Warning:** AI-resolved cherry-picks are never auto-verified, even if `auto-verify-cherry-picked-prs` is `true`.

If AI cannot resolve the conflict, or the cherry-pick failed for a non-conflict reason, the server falls back to the normal manual cherry-pick instructions.

5. Enable test-oracle recommendations

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        test-oracle:
          server-url: "http://localhost:8000"
          ai-provider: "claude"
          ai-model: "sonnet"
          test-patterns:
            - "tests/**/*.py"
          triggers:
            - approved
            # - pr-opened
            # - pr-synchronized
```

This block is separate from `ai-features`. Conventional-title suggestions and cherry-pick AI use the local sidecar, but `test-oracle` calls the external service at `server-url`.

| Trigger | When it runs |
| --- | --- |
| `approved` | When an approver uses `/approve` in a PR comment or a submitted review |
| `pr-opened` | When a PR is opened |
| `pr-synchronized` | When new commits are pushed to a PR |

`/test-oracle` always works on demand when the feature is configured, regardless of the trigger list.

> **Note:** The automatic `approved` trigger means the project's `/approve` command, not a plain GitHub review approval.


> **Tip:** The webhook server checks `GET /health` on the oracle before each analysis, so `/test-oracle` is the fastest end-to-end setup check.

6. Verify the result on a real PR

1. Open a new PR and check its welcome comment; enabled AI features are listed there with the provider and model in use.
2. Give the PR a non-conventional title and inspect the `conventional-title` check for `AI-Suggested Title` or `AI Auto-Fix Applied`.
3. From an account allowed to run comment commands, comment `/test-oracle`. If you enabled automatic triggers, also try `/approve` or push a new commit to exercise `approved` or `pr-synchronized`.
4. On the next cherry-pick that produces a merge conflict, confirm that the created PR gets `ai-resolved-conflicts` and a manual verification comment.

## Advanced Usage

```yaml
    ai-features:
      ai-provider: "claude"
      ai-model: "sonnet"
      conventional-title:
        enabled: true
        mode: suggest
        timeout-minutes: 5
      resolve-cherry-pick-conflicts-with-ai:
        enabled: true
        timeout-minutes: 20
```

Raise `timeout-minutes` when repos are large or conflicts are complicated. Title suggestions are usually quick, while cherry-pick resolution often benefits from a longer timeout.

```yaml
    services:
      github-webhook-server:
        environment:
          - CURSOR_API_KEY=xxx
          - ACPX_AGENTS=cursor
          # - VERTEX_CLAUDE_1M=true
          # - SIDECAR_PORT=9200
        volumes:
          - $HOME/.config/gcloud:/home/podman/.config/gcloud:ro
```

Use `ACPX_AGENTS=cursor` when you want Cursor model discovery. Add `VERTEX_CLAUDE_1M=true` plus a Google Cloud credential mount when you want Vertex-hosted 1M Claude models, and set `SIDECAR_PORT` if `9100` conflicts with another service.

> **Tip:** `/test-oracle` can still run on draft PRs, and it does not depend on the automatic trigger list. If you reopen a PR and want fresh recommendations, run `/test-oracle` manually.

## Troubleshooting

- **No AI suggestion appears in the title check.** Make sure the repo still has `conventional-title:` configured and `ai-features.conventional-title.enabled: true`. If `fix` mode does not rename the PR, open the failed check and look for `AI Auto-Fix Failed` or `AI Auto-Fix Skipped`.
- **The sidecar never becomes healthy.** If startup logs show `[sidecar] ERROR: sidecar failed to become healthy within 15s`, verify your provider credentials, any Google Cloud mount for Vertex, and that nothing else is using `SIDECAR_PORT`. If the sidecar is unavailable, local AI features will not run.
- **The oracle says it is not responding.** Make sure `server-url` is reachable from the webhook server and that `GET /health` returns `200`. Health-check failures are posted back to the PR; later analyze failures are logged on the server instead of commented back.
- **Cherry-picks still fall back to manual steps.** AI resolution only runs for real merge conflicts. Other cherry-pick errors still produce the normal manual instructions, and even successful AI resolutions must be reviewed by a human before merge.

## Related Pages

- [Configuration Reference](configuration-reference.html)
- [Environment Variables](environment-variables.html)
- [Webhook and Health API](webhook-and-health-api.html)
- [Run Pull Request Commands](run-pull-request-commands.html)
- [Manage Pull Requests](manage-pull-requests.html)


---

Source: secure-webhooks-and-pull-requests.md

# Secure Webhooks and Pull Requests

```yaml
    # config.yaml
    webhook-secret: your-random-secret-string
    verify-github-ips: true
    mask-sensitive-data: true

    repositories:
      my-repository:
        name: my-org/my-repository
        security-checks:
          mandatory: true
          committer-identity-check: true
          trusted-committers:
            - "pre-commit-ci[bot]"
```

This gives you the safest baseline for direct GitHub delivery: signed webhooks, GitHub source-IP filtering, masked logs, and blocking committer checks on pull requests. If you do not set `suspicious-paths`, the server already protects common sensitive locations such as `.claude/`, `.vscode/`, `.cursor/`, `.devcontainer/`, `.pi/`, `.github/workflows/`, and `.github/actions/`.

## Step-by-step

1. Set a webhook secret on the server and in GitHub.

```yaml
    webhook-secret: your-random-secret-string
```

Use the same value in your GitHub webhook or GitHub App callback settings and in `config.yaml`. If the server manages webhooks for you, it includes that secret when it creates the hook.

> **Warning:** If `webhook-secret` is unset, the server skips signature verification.


> **Tip:** See [Webhook and Health API](webhook-and-health-api.html) for the exact webhook callback path.

2. Turn on the right source-IP allowlist for your delivery path.

```yaml
    verify-github-ips: true
    # verify-cloudflare-ips: true
```

| Delivery path | Setting | Use it when |
| --- | --- | --- |
| GitHub connects directly to the server | `verify-github-ips: true` | GitHub is the client your app sees |
| Cloudflare proxies the webhook | `verify-cloudflare-ips: true` | Cloudflare is the client your app sees |
| You intentionally accept both paths | Set both to `true` | Either GitHub or Cloudflare may deliver the request |

When either allowlist is enabled, the server loads the current CIDR ranges during startup. If you enable IP verification and no valid ranges can be loaded, the service fails closed instead of starting insecurely.

> **Warning:** The allowlist check uses the client IP the app actually sees. If another proxy or load balancer sits in front of the server, you may end up validating the proxy IP instead of GitHub or Cloudflare.

3. Enable repository-level security checks.

```yaml
    repositories:
      my-repository:
        name: my-org/my-repository
        security-checks:
          mandatory: true
          suspicious-paths:
            - ".github/workflows/"
            - ".github/actions/"
          committer-identity-check: true
          trusted-committers:
            - "pre-commit-ci[bot]"
```

Use `suspicious-paths` for path prefixes that should trigger extra review, and keep `committer-identity-check: true` so the last committer must match the pull request author unless that committer is explicitly trusted.

| Setting | Result |
| --- | --- |
| `mandatory: true` | Security checks block merge readiness until they pass or are overridden by a maintainer |
| `mandatory: false` | Security checks still run, but they do not block merge readiness |

> **Note:** `trusted-committers` is best for expected automation identities. The server already trusts the GitHub App bot, GitHub `web-flow`, and GitHub API users tied to your configured `github-tokens`, so you usually only need to add extra bots or org-specific service accounts.

4. Keep security-relevant data masked in logs.

```yaml
    mask-sensitive-data: true
```

Leave masking enabled for normal operation. You can override it per repository, but only use that for short-lived debugging on a trusted system.

Masking works in two layers. Values the server holds — GitHub tokens, PyPI tokens, registry passwords — are matched by value and masked wherever they appear, including mid-sentence and at the end of a line, and including when they are passed to the logger as a formatting argument. A keyword layer backs that up for secrets the server never held, such as a rotated token or a value echoed by an external tool.

The keyword layer is deliberately broad, so ordinary text after a word like `token`, `login`, or `password` can render as `*****` in a log line. That is cosmetic. Log lines the server writes about token selection avoid those words and stay readable.

> **Warning:** Turning masking off can expose tokens, passwords, webhook URLs, and other secrets in log files. When it is off, log lines are written exactly as they were composed.

5. Keep optional internal endpoints on trusted networks only.

```yaml
    environment:
      - ENABLE_LOG_SERVER=true
      - ENABLE_MCP_SERVER=true
```

Only the literal string `true` enables either feature. If you turn them on, keep them behind a VPN or internal network, or put a reverse proxy with authentication and TLS in front of them.

> **Warning:** These feature flags do not add authentication by themselves. The safest public deployment is to leave them disabled unless you actively need them.

## Advanced Usage

Use a repository-local override when maintainers should own the security policy for one repository:

```yaml
    # .github-webhook-server.yaml
    security-checks:
      mandatory: false
      suspicious-paths:
        - ".github/workflows/"
        - ".github/actions/"
      committer-identity-check: true
      trusted-committers:
        - "pre-commit-ci[bot]"
```

That is useful when one team needs a different rollout pace than the server-wide default. See [Configure Repositories](configure-repositories.html) for when to keep a setting in central `config.yaml` versus the repository-local file.

If a mandatory security check blocks an expected automation flow, a maintainer can temporarily bypass it and later force a re-check:

```text
    /security-override
    /security-override cancel
```

Use this sparingly. The override is intended for reviewed exceptions, not for normal day-to-day merging. If you need to define who counts as a maintainer, see [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html).

If you want to start with visibility before enforcement, turn on the checks but make them advisory first:

```yaml
    security-checks:
      mandatory: false
      committer-identity-check: true
```

That lets teams see failures without blocking merges, then switch to `mandatory: true` once the trusted-committer list and suspicious path prefixes are settled.

## Troubleshooting

- **GitHub gets `403` immediately**
  - Check that `webhook-secret` matches on both sides.
  - Make sure GitHub is sending the `x-hub-signature-256` header.
  - If you recently rotated the secret, update GitHub too.

- **The server will not start after enabling IP verification**
  - Confirm the host can reach GitHub and/or Cloudflare to fetch allowlists.
  - Restart after fixing outbound network access.

- **A legitimate bot fails the committer check**
  - Add that login to `trusted-committers`.
  - Keep `committer-identity-check: true` enabled so only expected bots are exempted.

- **A pull request keeps failing on sensitive file checks**
  - Review whether the changed paths really belong in `suspicious-paths`.
  - Narrow the prefixes if they are too broad, or use a maintainer override for a one-off reviewed exception.

- **Optional internal endpoints are still reachable too broadly**
  - Do not rely on the feature flags alone.
  - Keep them on trusted networks, or add reverse-proxy authentication and TLS in front of them.

> **Tip:** See [Configuration Reference](configuration-reference.html) for the full list of security-related keys and defaults, and [Environment Variables](environment-variables.html) for startup flags that affect exposure.

## Related Pages

- [Webhook and Health API](webhook-and-health-api.html)
- [Configure Repositories](configure-repositories.html)
- [Environment Variables](environment-variables.html)
- [Configuration Reference](configuration-reference.html)
- [Set Up OWNERS and Review Rules](set-up-owners-and-reviewers.html)


---

Source: debug-with-the-log-viewer.md

# Debug with the Log Viewer

Use this page when a webhook was accepted but the automation still did not do what you expected. The log viewer lets you move from a GitHub delivery ID or PR number to the exact workflow steps and log lines that explain what happened.

## Prerequisites

- A running server instance. See [Start Automating a Repository](quick-start.html) if you have not deployed it yet.
- `ENABLE_LOG_SERVER=true` in the server environment.
- Access to the server from localhost, a VPN, or another trusted/private network.
- A GitHub delivery ID (`X-GitHub-Delivery`) or PR number to investigate.

## Quick Example

```bash
    ENABLE_LOG_SERVER=true WEBHOOK_SERVER_DATA_DIR=/path/to/data uv run entrypoint.py
```

1. Open `http://127.0.0.1:5000/logs`.
2. Paste the GitHub delivery ID into `Hook ID`.
3. Click the hook ID in the results to open the workflow timeline.
4. Click the failed step to see its status, duration, error, and matching log lines.

> **Note:** If you changed the server port in your config, use that port instead of `5000`.


> **Warning:** The `/logs` endpoints are unauthenticated. Keep them on trusted networks only. See [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) for deployment guidance.

## Step-by-Step

### 1. Enable the viewer and restart the server

If you run the server directly, start it with `ENABLE_LOG_SERVER=true`.

```bash
    ENABLE_LOG_SERVER=true WEBHOOK_SERVER_DATA_DIR=/path/to/data uv run entrypoint.py
```

If you run the container, add the same environment variable to the container and restart it.

> **Note:** The value must be the exact string `true`. Other values leave the viewer disabled. See [Environment Variables](environment-variables.html) for the full environment list.

### 2. Open recent history

Visit `/logs` in your browser. The page loads recent entries automatically and shows the newest items first.

Start with **Refresh** if the page is already open and you want the latest historical data from disk.

### 3. Narrow the results to the run you care about

Use the filters at the top of the page to reduce noise before you inspect a failure.

| If you know this | Use this control |
| --- | --- |
| GitHub delivery ID | `Hook ID` |
| Pull request number | `PR #` |
| Repository name | `Repository` |
| Username that triggered the event | `User` |
| Error text from a comment or status | `Search` |
| Only failures or warnings | `Level` |
| Rough time window | `Start Time` and `End Time` |

A few practical patterns work well:

1. Start with `Hook ID` if you have the delivery from GitHub.
2. Use `PR #` when one pull request generated several webhook deliveries.
3. Add `Level=ERROR` or `Level=WARNING` when you only want failing or suspicious entries.
4. Add a time range if the server manages many repositories.

The stats bar helps you judge whether the filter is tight enough:

- **Shown**: how many entries are on screen now.
- **Total**: an estimate of how many logs exist overall.
- **Scanned**: how many entries the server examined for the current query.

> **Tip:** If **Scanned** shows a `+` suffix or `(partial scan)`, narrow the query with `Hook ID`, `Repository`, `Level`, or a time window before trusting the result set.

### 4. Open the workflow for one webhook delivery

Click the hook ID shown inside a log entry. That opens the workflow timeline for that delivery.

Use the timeline when you want to answer questions like:

- Did the server stop at validation, cloning, checks, or mergeability?
- Which step failed first?
- How long did the run take?
- Was the webhook skipped on purpose?

If you only know the pull request number, click the PR number in a log entry first. The PR modal lists every unique webhook delivery ID it found for that PR, and you can open any one of them from there.

### 5. Inspect the failing step

Inside the workflow timeline, click the step that failed or looks slow. The page shows two layers of detail:

1. The step summary:
   - status
   - duration
   - recorded error, if one exists
2. The log lines that happened during that step

This is the fastest way to answer “what actually broke?” without reading the entire log stream.

> **Note:** Even if no matching log lines are found for a step, the viewer still shows the step’s own status, duration, and recorded error details.

### 6. Save or share the result

Once the page shows the right slice of data, click **Export JSON**. The export uses the current filters and the current results limit.

This is useful when you want to:

- attach evidence to an issue
- compare repeated failures offline
- hand another maintainer the exact filtered result set

> **Note:** **Clear Logs** only clears the current browser view. It does not delete log files from the server.

## Advanced Usage

### Watch a problem live

Use real-time mode when you are about to reproduce a problem.

1. Set the filters you want first.
2. Click **Start Real-time**.
3. Reproduce the action in GitHub.
4. Watch new entries appear at the top of the page.
5. Click **Stop Real-time** when you are done.

Server-side streaming is most useful with these filters:

- `Hook ID`
- `PR #`
- `Repository`
- `User`
- `Level`

> **Note:** Search text still helps while streaming because the page filters messages in the browser. Time-range filters are most useful when reloading historical entries, not when following a live stream.

### Export a filtered snapshot from the shell

If you want the same filtered data without using the button, download it directly.

```bash
    curl -OJ "http://127.0.0.1:5000/logs/api/export?format_type=json&hook_id=<delivery-id>&level=ERROR&limit=5000"
```

If you want the step timeline directly, fetch it by delivery ID.

```bash
    curl "http://127.0.0.1:5000/logs/api/workflow-steps/<delivery-id>"
```

See [Log Viewer and MCP API](log-viewer-and-mcp-api.html) for the full set of routes and query parameters.

### Handle large result sets safely

The viewer is designed to avoid loading unlimited logs at once. You will get better results if you narrow first and expand second.

Use this order when the server is busy:

1. Filter by `Repository`.
2. Add `PR #` or `Hook ID`.
3. Add `Level=ERROR` if you only care about failures.
4. Increase the results limit only after the query is already specific.

> **Warning:** JSON exports over 50,000 entries are rejected. If you hit that limit, split the export by time range, repository, or PR.

## Troubleshooting

**Which API token handled this delivery, and why?**

Token-selection lines carry the delivery ID, so concurrent deliveries can be told apart:

```
    Get API and tokens for repository myk-org/repo [3f0c9a2e-7b41-4d8e-9a10-2c5b8e6f1d33]
    [myakove-bot] API rate limit: 4873 of 5000
    [deadlock1bot] API rate limit: 4582 of 5000
    Get API and tokens for repository myk-org/repo [3f0c9a2e...] API user myakove-bot selected with highest rate limit: 4873
```

Every configured token is probed and the one with the highest enforced remaining budget wins. The bracketed name is the GitHub login the token belongs to. A `[user] API rate limit: N of 5000` line below 700 remaining is logged at warning level.

Per-delivery spend appears when the delivery completes:

```
    API spend: myakove-bot 3 API calls (initial: 4999, remaining: 4996, reset in 3595s)
```

`initial` is the budget at the start of the delivery, `remaining` what was left after it, and `reset in` the seconds until GitHub refills the window.

**`/logs` returns 404**

- Make sure `ENABLE_LOG_SERVER=true` is set exactly.
- Restart the server after changing environment variables.

**The page opens but no entries appear**

- Confirm `WEBHOOK_SERVER_DATA_DIR` points at the same data directory the server is using.
- Confirm that directory has a `logs/` subdirectory with recent log files.
- If GitHub showed a `200` delivery, remember that only means the webhook was accepted and queued. See [Webhook and Health API](webhook-and-health-api.html) for those semantics.

**A PR changed nothing even though the delivery succeeded**

- Search by `Hook ID` first, then open the workflow timeline.
- Some deliveries are intentionally skipped, such as certain draft-PR, pending-status, or unsupported event cases. See [Supported GitHub Events](supported-github-events.html) for the expected behavior.

**The workflow opens but step logs do not load**

- Use the viewer from localhost, a VPN, or another trusted/private network.
- If you are behind a reverse proxy, make sure the server can still determine the real client IP.

**You keep seeing `(partial scan)`**

- Narrow the query with `Hook ID`, `Repository`, `Level`, or time filters.
- Export smaller slices instead of one large all-history query.

## Related Pages

- [Log Viewer and MCP API](log-viewer-and-mcp-api.html)
- [Webhook and Health API](webhook-and-health-api.html)
- [Supported GitHub Events](supported-github-events.html)
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html)
- [Environment Variables](environment-variables.html)


---

Source: automation-recipes.md

# Automation Recipes

Short, copy-pasteable walkthroughs of common maintainer tasks. Every key here is defined in the configuration schema; for full key listings see [Configuration Reference](configuration-reference.html).

## Restrict a repository to specific webhook events

**When you'd want this:** a docs-only or dependency repo doesn't need `push` and `issue_comment` traffic, and a smaller event set means less webhook noise and fewer API calls.

Set `repositories.<repo-id>.events` in `config.yaml`. The server creates or edits the repository webhook at startup to match exactly this list. Omit the key to get the default of `["*"]` (all events).

```yaml
    # config.yaml
    repositories:
      my-docs-site:
        name: org/repo
        events:
          - pull_request
          - issue_comment
```

Verify what was registered:

```bash
    gh api repos/org/repo/hooks --jq '.[] | select(.config.url | contains("hooks")) | .events'
```

> **Note:** `events` is read from `config.yaml` only. A repo-local `.github-webhook-server.yaml` is not consulted for it, and the change takes effect on the next server start.

## Enable auto-merge for chosen branches

**When you'd want this:** your release branch should merge on green without a human pressing the button.

```yaml
    # config.yaml
    repositories:
      my-service:
        name: org/repo
        set-auto-merge-prs:
          - main
          - release
```

Branch names are matched exactly against the PR base branch. Auto-merge is set with `SQUASH` as the merge method.

### The security interaction

A PR that touches a `security-checks.suspicious-paths` prefix does **not** get auto-merge, even on a configured branch. When a match is found the server:

1. comments `Auto-merge blocked: PR modifies security-sensitive paths: ...` on the PR,
2. disables auto-merge if it was already enabled on the PR.

Default prefixes: `.claude/`, `.vscode/`, `.cursor/`, `.devcontainer/`, `.pi/`, `.github/workflows/`, `.github/actions/`.

A maintainer can clear the block with a PR comment:

```
    /security-override
```

That sets the `security-suspicious-paths` check run to success, and `set_pull_request_automerge()` then allows auto-merge through. `/security-override cancel` re-runs the security checks. Only maintainers can use the command.

Narrow the prefixes per repository when a repo legitimately owns these paths:

```yaml
    # config.yaml
    repositories:
      my-monorepo:
        name: org/repo
        security-checks:
          suspicious-paths:
            - .github/workflows/release-only/
```

Cherry-pick PRs whose conflicts were resolved by AI are never auto-merged, even on a configured branch.

## Add custom check runs

**When you'd want this:** you want one more gate in the PR pipeline — a linter, a security scan — without writing a new check into the server.

`custom-check-runs` is a repository-scoped list. Each entry needs `name` and `command`; `mandatory` defaults to `true`.

```yaml
    # config.yaml
    repositories:
      my-service:
        name: org/repo
        custom-check-runs:
          - name: lint
            command: uv tool run --from ruff ruff check
            mandatory: true
          - name: security-scan
            command: TOKEN=xyz DEBUG=true uv tool run --from bandit bandit -r .
            mandatory: false
```

- The command runs in the repository worktree. Leading `VAR=value` assignments are allowed and skipped when the executable is resolved.
- The server resolves the executable with `shutil.which()` at startup. If it is not on the server, the check is skipped with a warning — the check silently does not exist.
- `mandatory: true` puts the check in the required-check list so it blocks merge. `mandatory: false` runs it and reports the result without blocking.
- Names must match `^[a-zA-Z0-9._-]{1,64}$` and be unique.
- Names must not collide with `BUILTIN_CHECK_NAMES` in `webhook_server/utils/constants.py`: `tox`, `pre-commit`, `build-container`, `python-module-install`, `conventional-title`, `can-be-merged`, `security-suspicious-paths`, `security-committer-identity`. A colliding check is skipped with a warning.
- Valid custom names work with `/retest <name>`.

Re-run a check on an open PR:

```
    /retest lint
```

## Require labels before a PR can be merged

**When you'd want this:** a PR must carry a `security-reviewed` (or similar) label before `can-be-merged` passes.

```yaml
    # config.yaml
    repositories:
      my-service:
        name: org/repo
        can-be-merged-required-labels:
          - security-reviewed
          - ready
```

Every listed label must be present on the PR. Missing ones are reported in the `can-be-merged` check output as `Missing required labels: ...`.

## Pre-verify trusted users so their PRs merge automatically

**When you'd want this:** bots and senior maintainers should not need a second approval on every PR.

```yaml
    # config.yaml
    auto-verified-and-merged-users:
      - renovate[bot]
      - my-org-release-bot

    repositories:
      my-service:
        name: org/repo
        # optional per-repo replacement
        auto-verified-and-merged-users:
          - renovate[bot]
          - alice
          - bob
        # cherry-picked PRs are auto-verified by default
        auto-verify-cherry-picked-prs: true
```

Behavior:

- When the PR's parent committer is in the list, the `verified` label is added, the `verified` check run is set to success, and auto-merge is enabled regardless of branch.
- A new commit pushed to the PR resets the `verified` label to queued.
- Users from `github-tokens` (the API users) are added to the list at runtime, so those PRs are auto-verified too.
- `auto-verify-cherry-picked-prs: false` removes the `verified` label from cherry-picked PRs and forces the manual path. The default is `true`.

## Override a repository's configuration locally

**When you'd want this:** a repository wants to turn off a check for itself without a server-side config change.

Precedence is first-value-wins: repo-local `.github-webhook-server.yaml` → `config.yaml` `repositories.<repo-id>` → `config.yaml` root.

```yaml
    # .github-webhook-server.yaml (in org/repo, at the repository root)
    pre-commit: false
    minimum-lgtm: 2
    set-auto-merge-prs:
      - main
```

Arrays and scalars are replaced wholesale, not merged. Two exceptions:

- `branch-protection` overlays property by property.
- `labels` overlays global, and `labels.colors` merges by key.

Blocks `ai-features`, `security-checks`, `test-oracle`, and `pr-size-thresholds` replace the global block entirely rather than deep-merging — repeat required subkeys when overriding.

`events`, `name`, `log-level`, `log-file`, `mask-sensitive-data`, `github-tokens`, `default-status-checks`, `protected-branches`, `allow-commands-on-draft-prs`, and `pr-size-thresholds` are never read from `.github-webhook-server.yaml`. They belong in `config.yaml`.

An empty string in `welcome-extra-info` clears the inherited value, and a `.github-webhook-server-welcome-message.md` file in the repository overrides the configured text entirely.


---

Source: configuration-reference.md

# Configuration Reference

Reference for `config.yaml`, repository entries under `repositories.<repo-id>`, and repository-local `.github-webhook-server.yaml` overrides. See [Configure Repositories](configure-repositories.html) for rollout patterns, [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html) for workflow examples, [Enable AI Features](enable-ai-features.html) for AI setup, [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) for hardening, [Supported GitHub Events](supported-github-events.html) for event coverage, and [Environment Variables](environment-variables.html) for non-YAML settings.

## Files and Resolution

| Scope label | Location |
|---|---|
| `Global` | Root of `config.yaml` |
| `Repo` | `config.yaml` under `repositories.<repo-id>` |
| `Local` | Root of `.github-webhook-server.yaml` |

| File | Purpose | Effect |
|---|---|---|
| `config.yaml` | Server-wide settings and per-repository registration | Required. Defines global defaults, managed repositories, webhook subscriptions, branch settings, and runtime behavior. |
| `.github-webhook-server.yaml` | Repository-local runtime overrides | Optional. Overrides only the keys the webhook runtime reads from the repository after startup. |

| Resolution rule | Effect |
|---|---|
| Scalar and array values | First defined value wins: `Local` -> `Repo` -> `Global`. |
| `branch-protection` | Repo values overlay global values property by property. |
| `labels` | Repo/local values overlay global values; `labels.colors` entries merge by key. |
| `welcome-extra-info` | Empty string clears an inherited value. A repo file named `.github-webhook-server-welcome-message.md` overrides configured text entirely. |
| `ai-features`, `security-checks`, `test-oracle`, `pr-size-thresholds` | A repo-scoped block replaces the global block instead of deep-merging it. Repeat required subkeys when overriding. |

> **Warning:** `.github-webhook-server.yaml` is not consulted for `name`, `log-level`, `log-file`, `mask-sensitive-data`, `github-tokens`, `events`, `default-status-checks`, `protected-branches`, `allow-commands-on-draft-prs`, `test-oracle`, or `pr-size-thresholds`. Put those keys in `config.yaml`.

> **Note:** A repo-local `branch-protection` block only affects webhook-time `required_conversation_resolution` handling. GitHub branch protection updates at startup still come from `config.yaml`.

```yaml
    # config.yaml
    github-app-id: 123456
    github-tokens:
      - ghp_admin_token
    webhook-ip: https://hooks.example.com/webhook_server

    repositories:
      github-webhook-server:
        name: my-org/github-webhook-server
        pre-commit: true
        conventional-title: "feat,fix,docs"

    # .github-webhook-server.yaml
    pre-commit: false
    conventional-title: "feat,fix,docs,refactor"
```

## Global Keys

### Logging

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `log-level` | `string` | `INFO` | Main application log level. Allowed values: `INFO`, `DEBUG`. | Controls webhook server log verbosity. |
| `log-file` | `string` | unset | Main application log file path. Relative paths resolve under `<data-dir>/logs/`. | Writes main server logs to this file; omit for console-only main logs. |
| `mcp-log-file` | `string` | `mcp_server.log` | MCP server log file path. Relative paths resolve under `<data-dir>/logs/`. | Writes `/mcp` server logs when MCP is enabled. |
| `logs-server-log-file` | `string` | `logs_server.log` | Log viewer server log file path. Relative paths resolve under `<data-dir>/logs/`. | Writes `/logs` server logs when the log viewer is enabled. |
| `mask-sensitive-data` | `boolean` | `true` | Redacts tokens, passwords, webhook secrets, registry credentials, and similar values from logs. | Applies log masking across the server unless a repo-level `config.yaml` override is present. Set to `false` to return log lines exactly as written. |

```yaml
    log-level: INFO
    log-file: webhook-server.log
    mcp-log-file: mcp_server.log
    logs-server-log-file: logs_server.log
    mask-sensitive-data: true
```

### GitHub Access and Network

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `github-app-id` | `integer` | unset | GitHub App ID used by the server. | Identifies the GitHub App when the server looks up app metadata and manages repositories. |
| `github-tokens` | `array<string>` | unset | Personal access tokens the server can use for repository API calls. | Every configured token is probed and the one with the highest enforced remaining rate limit is selected. Exhausted, revoked, and unreachable tokens are skipped. |
| `webhook-ip` | `string` | unset | Full webhook callback URL, including path. | Registered on managed repositories as the webhook target URL. |
| `webhook-secret` | `string` | unset | Shared webhook secret. | Enables HMAC-SHA256 validation of incoming GitHub webhook payloads. |
| `verify-github-ips` | `boolean` | `false` | Restrict incoming requests to GitHub’s published webhook IP ranges. | Loads GitHub CIDRs at startup and rejects requests outside the allowlist. |
| `verify-cloudflare-ips` | `boolean` | `false` | Restrict incoming requests to Cloudflare’s published IP ranges. | Loads Cloudflare CIDRs at startup and rejects requests outside the allowlist. |
| `disable-ssl-warnings` | `boolean` | `false` | Disable urllib3 SSL warnings. | Suppresses SSL warning noise in logs. |
| `ip-bind` | `string` | `0.0.0.0` | Interface address for the HTTP server. | Controls which network interface the server listens on. |
| `port` | `integer` | `5000` | HTTP server port. | Controls the listening port for webhook and API endpoints. |
| `max-workers` | `integer` | `10` | Maximum Uvicorn worker count. | Used in production mode; ignored when dev reload mode is enabled. |

> **Warning:** If `verify-github-ips` or `verify-cloudflare-ips` is enabled and no allowlist loads successfully, the server fails closed and does not start.

```yaml
    github-app-id: 123456
    github-tokens:
      - ghp_primary_token
      - ghp_fallback_token
    webhook-ip: https://hooks.example.com/webhook_server
    webhook-secret: <webhook-secret>
    verify-github-ips: true
    verify-cloudflare-ips: true
    disable-ssl-warnings: false
    ip-bind: 0.0.0.0
    port: 5000
    max-workers: 10
```

### Global Repository Defaults

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `default-status-checks` | `array<string>` | `[]` | Seed list of required status checks for protected branches. | When a branch is listed under `protected-branches`, the server starts with this list, always adds `can-be-merged`, then appends built-in checks implied by repo settings. |
| `auto-verified-and-merged-users` | `array<string>` | `[]` | Users whose PRs are treated as auto-verified. | Applies as the global fallback user list; API users from `github-tokens` are added at runtime. |
| `auto-verify-cherry-picked-prs` | `boolean` | `true` | Global default for cherry-picked PR auto-verification. | Repo-level value can disable or re-enable automatic verification of cherry-picked PRs. |
| `create-issue-for-new-pr` | `boolean` | `true` | Global default for PR issue creation. | Controls whether new PRs create a tracking issue by default. |
| `cherry-pick-assign-to-pr-author` | `boolean` | `true` | Global default for cherry-pick assignee behavior. | Controls whether cherry-pick PRs are assigned to the original PR author by default. |
| `allow-commands-on-draft-prs` | `array<string>` | unset | Global draft-PR command allowlist. Use slash-command names without `/`. | Omitted blocks draft-PR commands, `[]` allows all, and a non-empty list allows only the listed commands. |

```yaml
    default-status-checks:
      - ci/external
      - policy/manual-approval
    auto-verified-and-merged-users:
      - renovate[bot]
    auto-verify-cherry-picked-prs: true
    create-issue-for-new-pr: true
    cherry-pick-assign-to-pr-author: true
    allow-commands-on-draft-prs:
      - retest
      - build-and-push-container
```

### `docker`

Where: `Global`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `docker.username` | `string` | unset | Docker Hub username. Required when the `docker` block is present. | Used for startup `docker.io` login. |
| `docker.password` | `string` | unset | Docker Hub password or token. Required when the `docker` block is present. | Used for startup `docker.io` login. |

```yaml
    docker:
      username: dockerhub-user
      password: <dockerhub-token>
```

## Repository Registration

### `repositories`

Where: `Global`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `repositories` | `object` | none | Non-empty map of managed repositories. | Required. Registers the repositories the server manages. |
| `repositories.<repo-id>` | `object` | none | Per-repository configuration block. `<repo-id>` must match the GitHub repository name from the webhook payload, not `owner/repo`. | Selects the correct repo config at webhook time. |
| `repositories.<repo-id>.name` | `string` | none | Full repository name in `owner/repo` format. | Used for GitHub API access, webhook registration, and repo setup. |

```yaml
    repositories:
      github-webhook-server:
        name: my-org/github-webhook-server
      docsfy:
        name: my-org/docsfy
```

## Repository Keys

### Repo Keys Read From `config.yaml` Only

Where: `Repo`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `log-level` | `string` | inherits global; else `INFO` | Per-repo log level. Allowed values: `INFO`, `DEBUG`. | Overrides global log verbosity for this repository. |
| `log-file` | `string` | inherits global | Per-repo log file path. Relative paths resolve under `<data-dir>/logs/`. | Overrides the main log destination for this repository. |
| `mask-sensitive-data` | `boolean` | inherits global; else `true` | Per-repo log masking override. | Enables or disables secret redaction for this repository’s logs. |
| `github-tokens` | `array<string>` | inherits global | Per-repo token list. | Replaces the global token list for this repository’s API selection. |
| `events` | `array<string>` | `["*"]` | GitHub webhook event names to subscribe to for this repository. | Controls the repository webhook subscription created or updated at startup. |
| `default-status-checks` | `array<string>` | inherits global; else `[]` | Per-repo replacement for the global seed list. | Used when protected branches are configured for this repository. |
| `allow-commands-on-draft-prs` | `array<string>` | inherits global; else unset | Per-repo draft-PR command allowlist. Use slash-command names without `/`. | Omitted blocks draft-PR commands, `[]` allows all, non-empty list allows only the listed commands. |

> **Note:** For event behavior after delivery, see [Supported GitHub Events](supported-github-events.html).

```yaml
    repositories:
      github-webhook-server:
        name: my-org/github-webhook-server
        log-level: DEBUG
        log-file: github-webhook-server.log
        mask-sensitive-data: true
        github-tokens:
          - ghp_repo_specific_token
        events:
          - pull_request
          - issue_comment
          - push
          - check_run
          - status
        default-status-checks:
          - ci/external
        allow-commands-on-draft-prs:
          - retest
          - build-and-push-container
```

### Repo Keys Read From `config.yaml` or `.github-webhook-server.yaml`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `slack-webhook-url` | `string` | unset | Slack incoming webhook URL. | Sends Slack notifications for container and PyPI publish results and failures. |
| `verified-job` | `boolean` | `true` | Enable the `verified` workflow. | Adds the `verified` check to merge logic and required-check generation. |
| `pre-commit` | `boolean` | `false` | Enable pre-commit execution for PR checks. | Adds the `pre-commit` check run for this repository. |
| `tox-python-version` | `string` | unset | Legacy tox Python version key. | Used only when `tox.python-version` is absent; emits a deprecation warning. |
| `auto-verified-and-merged-users` | `array<string>` | inherits global; else `[]` | Per-repo replacement for the auto-verified user list. | Limits auto-verification to the listed users for this repository. |
| `auto-verify-cherry-picked-prs` | `boolean` | inherits global; else `true` | Per-repo cherry-pick auto-verification setting. | Controls whether eligible cherry-picked PRs are auto-verified. |
| `set-auto-merge-prs` | `array<string>` | `[]` | Exact base branch names that should have auto-merge enabled. | If a PR targets a listed branch and becomes mergeable, the server enables GitHub auto-merge. |
| `can-be-merged-required-labels` | `array<string>` | `[]` | Labels that must be present before a PR can be marked mergeable. | Adds extra label gates to the `can-be-merged` workflow. |
| `conventional-title` | `string` | unset | Comma-separated allowed Conventional Commit types, or `*` for any valid type. | Enables the `conventional-title` check run for this repository. |
| `minimum-lgtm` | `integer` | `0` | Minimum LGTM count. | Requires this many LGTM approvals before the PR can satisfy merge rules. |
| `create-issue-for-new-pr` | `boolean` | inherits global; else `true` | Per-repo tracking-issue setting. | Overrides the global issue-creation behavior for new PRs. |
| `cherry-pick-assign-to-pr-author` | `boolean` | inherits global; else `true` | Per-repo cherry-pick assignee setting. | Overrides whether cherry-pick PRs are assigned to the original PR author. |

> **Warning:** `pre-commit` is runtime-disabled until you set it to `true`, even though the schema advertises a `true` default.

```yaml
    # Either under repositories.<repo-id> in config.yaml
    # or at the root of .github-webhook-server.yaml
    slack-webhook-url: https://hooks.slack.com/services/TEAM/CHANNEL/TOKEN
    verified-job: true
    pre-commit: true
    conventional-title: "feat,fix,docs,refactor"
    minimum-lgtm: 2
    set-auto-merge-prs:
      - main
    can-be-merged-required-labels:
      - approved
      - security-reviewed
    create-issue-for-new-pr: false
    cherry-pick-assign-to-pr-author: true
```

## Shared Blocks

### `branch-protection`

Where: `Global` or `Repo`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `branch-protection.strict` | `boolean` | `true` | Use strict status checks. | GitHub requires the branch to be up to date before merging. Also gates `can-be-merged`: a PR behind its base branch fails the check. See the note below. |
| `branch-protection.require_code_owner_reviews` | `boolean` | `false` | Require code owner reviews. | GitHub enforces code owner review approval before merge. |
| `branch-protection.dismiss_stale_reviews` | `boolean` | `true` | Dismiss stale reviews after new commits. | GitHub invalidates earlier approvals on newer commits. |
| `branch-protection.required_approving_review_count` | `integer` | `0` | Required GitHub approval count. | GitHub enforces the numeric approval threshold. |
| `branch-protection.required_linear_history` | `boolean` | `true` | Require linear commit history. | GitHub blocks non-linear merge history. |
| `branch-protection.required_conversation_resolution` | `boolean` | `true` | Require resolved review conversations. | GitHub enforces conversation resolution, and the webhook runtime listens to review-thread events only when this is enabled. |

> **Note:** Repo `branch-protection` values overlay global values field by field.

> **Note:** The `can-be-merged` check reads the strict setting from GitHub's branch protection on the PR's base branch at evaluation time, not from this config, so a change made in GitHub takes effect on the next event and the cached value never outlives a single event. When GitHub does not expose branch protection (no protection configured, or the token lacks permission), the behind-base check is skipped rather than guessed from config, because a stale value here would fail PRs GitHub would happily merge. The same unreadable protection makes the required status check read fail closed instead of reporting no required checks. The config value still applies when the server sets up branch protection itself.

```yaml
    branch-protection:
      strict: true
      require_code_owner_reviews: false
      dismiss_stale_reviews: true
      required_approving_review_count: 1
      required_linear_history: true
      required_conversation_resolution: true

    repositories:
      github-webhook-server:
        name: my-org/github-webhook-server
        branch-protection:
          require_code_owner_reviews: true
          required_approving_review_count: 2
```

### `labels`

Where: `Global` or `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `labels.enabled-labels` | `array<string>` | all configurable categories enabled | Enabled label categories. Valid values: `verified`, `hold`, `wip`, `needs-rebase`, `has-conflicts`, `can-be-merged`, `size`, `branch`, `cherry-pick`, `automerge`. | Restricts which auto-managed label families the server creates and updates. |
| `labels.colors` | `object<string,string>` | `{}` | Color overrides using CSS3 color names. | Overrides default label colors. |
| `labels.colors.<label-or-prefix>` | `string` | unset | Exact label name such as `hold`, or a dynamic label prefix ending in `-` such as `approved-` or `branch-`. | Applies the configured color when matching labels are created or updated. |

> **Note:** Reviewed-by labels such as `approved-*`, `lgtm-*`, `commented-*`, and `changes-requested-*` are always enabled.


> **Tip:** Use `pr-size-thresholds` to control `size/*` label names and colors.

```yaml
    labels:
      enabled-labels:
        - verified
        - hold
        - size
        - branch
      colors:
        hold: red
        verified: green
        approved-: blue
        branch-: darkorange
```

### `welcome-extra-info`

Where: `Global` or `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `welcome-extra-info` | `string` | inherits outer scope; else empty string | Markdown appended to the PR welcome comment. Maximum runtime size is 10 KB UTF-8. An empty string explicitly clears an inherited value. | Adds extra guidance to the welcome comment unless a repo file overrides it. |

> **Note:** If `.github-webhook-server-welcome-message.md` exists in the repository, its contents replace `welcome-extra-info`. An empty file suppresses configured welcome text.

```yaml
    welcome-extra-info: |
      Please link the tracking issue.
      Review the release checklist before merging.
```

### `security-checks`

Where: `Global` or `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `security-checks.mandatory` | `boolean` | `true` | Make security checks blocking instead of advisory. | When `true`, security checks join required merge checks. |
| `security-checks.suspicious-paths` | `array<string>` | built-in sensitive-path set | Path prefixes treated as sensitive. | PRs that modify matching paths fail the `security-suspicious-paths` check. |
| `security-checks.committer-identity-check` | `boolean` | `true` | Compare the PR author with the last commit committer. | Fails the `security-committer-identity` check when the identities do not match and no trust exception applies. |
| `security-checks.trusted-committers` | `array<string>` | `[]` | Additional trusted committer logins. | Allows listed committers to pass the identity check; entries are normalized to lowercase. |

> **Note:** Built-in `suspicious-paths` defaults are `.claude/`, `.vscode/`, `.cursor/`, `.devcontainer/`, `.pi/`, `.github/workflows/`, and `.github/actions/`.


> **Note:** The server automatically trusts the GitHub App bot login, `web-flow`, and the API users behind `github-tokens`.

```yaml
    security-checks:
      mandatory: true
      suspicious-paths:
        - .github/workflows/
        - .github/actions/
        - Dockerfile
      committer-identity-check: true
      trusted-committers:
        - pre-commit-ci[bot]
        - release-bot
```

### `ai-features`

Where: `Global` or `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `ai-features.ai-provider` | `string` | none | AI provider. Allowed values: `claude`, `gemini`, `cursor`. Required when the block is present. | Selects the provider for all AI features in the block. |
| `ai-features.ai-model` | `string` | none | Model identifier. Required when the block is present. | Selects the model for all AI features in the block. |
| `ai-features.conventional-title.enabled` | `boolean` | none | Enable AI assistance for the `conventional-title` check. Required when the sub-block is present. | Turns AI title suggestions or auto-fixes on for Conventional Commit validation failures. |
| `ai-features.conventional-title.mode` | `string` | `suggest` | Allowed values: `suggest`, `fix`. | `suggest` writes a suggestion into the check run; `fix` updates the PR title automatically. |
| `ai-features.conventional-title.timeout-minutes` | `integer` | `10` | AI CLI timeout in minutes. Minimum `1`. | Limits how long the AI title step can run. |
| `ai-features.resolve-cherry-pick-conflicts-with-ai.enabled` | `boolean` | none | Enable AI cherry-pick conflict resolution. Required when the sub-block is present. | Lets the server attempt AI conflict resolution for cherry-pick failures. |
| `ai-features.resolve-cherry-pick-conflicts-with-ai.timeout-minutes` | `integer` | `10` | AI CLI timeout in minutes. Minimum `1`. | Limits how long the AI cherry-pick resolution step can run. |

> **Warning:** Repo/local `ai-features` replaces the global block. Repeat `ai-provider` and `ai-model` in every override.


> **Note:** Cherry-picks resolved with AI are never auto-verified.

```yaml
    ai-features:
      ai-provider: claude
      ai-model: sonnet
      conventional-title:
        enabled: true
        mode: suggest
        timeout-minutes: 10
      resolve-cherry-pick-conflicts-with-ai:
        enabled: true
        timeout-minutes: 10
```

### `test-oracle`

Where: `Global` or `Repo`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `test-oracle.server-url` | `string` | none | Base URL of the test oracle service. Required when the block is present. | The webhook server calls this service for recommendations. |
| `test-oracle.ai-provider` | `string` | none | Provider name. Allowed values: `claude`, `gemini`, `cursor`. Required when the block is present. | Sent to the oracle service for model selection. |
| `test-oracle.ai-model` | `string` | none | Model identifier. Required when the block is present. | Sent to the oracle service for model selection. |
| `test-oracle.test-patterns` | `array<string>` | service defaults | Test file globs. | Restricts which test paths the oracle recommends from. |
| `test-oracle.triggers` | `array<string>` | `["approved"]` | Automatic trigger names. Allowed values: `approved`, `pr-opened`, `pr-synchronized`. | Controls when the server runs automatic oracle analysis. |

> **Note:** `approved` refers to the `/approve` command path used by this server. The `/test-oracle` command works whenever the block is configured.


> **Warning:** Repo `test-oracle` replaces the global block. `.github-webhook-server.yaml` does not override this block.

```yaml
    test-oracle:
      server-url: http://test-oracle.internal:8000
      ai-provider: claude
      ai-model: sonnet
      test-patterns:
        - tests/**/*.py
      triggers:
        - approved
        - pr-opened
```

### `pr-size-thresholds`

Where: `Global` or `Repo`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `pr-size-thresholds.<label>.threshold` | `integer` or `string` | none | Exclusive upper bound for the bucket. Use a positive integer or the string `inf`. | The first threshold greater than total additions plus deletions wins. |
| `pr-size-thresholds.<label>.color` | `string` | `lightgray` | CSS3 color name for `size/<label>`. | Sets the label color for the bucket. |

> **Note:** Built-in thresholds are `size/XS` for `<20`, `size/S` for `<50`, `size/M` for `<100`, `size/L` for `<300`, `size/XL` for `<500`, and `size/XXL` otherwise.


> **Warning:** Repo `pr-size-thresholds` replaces the global block. `.github-webhook-server.yaml` does not override this block.

```yaml
    pr-size-thresholds:
      XS:
        threshold: 20
        color: lightgray
      M:
        threshold: 100
        color: orange
      XXL:
        threshold: inf
        color: darkred
```

## Repository-Only Blocks

### `tox`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `tox.<base-branch>` | `string` | unset | Exact PR base branch name mapped to a tox env list, or the literal value `all`. | If the PR base branch matches exactly, the value becomes the `tox -e` selection; `all` runs tox without `-e`. |
| `tox.args` | `string` | empty string | Extra CLI arguments appended to the generated tox command. | Modifies the tox invocation for every PR in this repository. |
| `tox.python-version` | `string` | unset | Python version passed to `uvx` as `--python=<version>`. | Selects the Python runtime used to launch tox. |
| `tox-python-version` | `string` | unset | Deprecated legacy form of `tox.python-version`. | Used only when `tox.python-version` is absent. |

> **Warning:** Use exact branch names and string env lists. The runtime does not expand branch globs, and array branch values are not normalized before execution.


> **Note:** If a `tox` block exists but no branch key matches the PR base branch, tox still runs with the repository’s default tox configuration.

```yaml
    tox:
      main: all
      develop: unit,lint
      args: "-p -v"
      python-version: "3.11"
```

### `protected-branches`

Where: `Repo`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `protected-branches.<branch>` | `object` | unset | Exact branch name to configure at startup. Use `{}` for automatic required checks. | The startup repository setup applies GitHub branch protection to this branch. |
| `protected-branches.<branch>.include-runs` | `array<string>` | `[]` | Explicit required status checks. | If non-empty, this becomes the branch’s required-check list, and the security checks governed by `security-checks.mandatory` (`security-suspicious-paths`, `security-committer-identity`) are added on top of it. |
| `protected-branches.<branch>.exclude-runs` | `array<string>` | `[]` | Status checks to remove. | Subtracted from the branch’s required-check list in both paths, removing any entry it names — including the security checks. |

> **Warning:** Use exact branch names and the object form shown below. The schema accepts array shorthand, but the startup branch-settings path reads the object form.

```yaml
    repositories:
      github-webhook-server:
        name: my-org/github-webhook-server
        protected-branches:
          main: {}
          develop:
            include-runs:
              - can-be-merged
              - verified
              - tox
            exclude-runs:
              - pre-commit.ci - pr
```

### `pypi`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `pypi.token` | `string` | unset | PyPI token. Required when the `pypi` block is present. | Enables package publishing on the repository’s release/tag workflow. |

```yaml
    pypi:
      token: <pypi-token>
```

### `container`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `container.username` | `string` | none | Registry username. Required when the `container` block is present. | Used for image push credentials. |
| `container.password` | `string` | none | Registry password or token. Required when the `container` block is present. | Used for image push credentials. |
| `container.repository` | `string` | none | Full image repository name. Required when the `container` block is present. | Target image repository for builds and pushes. |
| `container.tag` | `string` | `latest` | Default main/master release tag. | Used for merged PRs targeting `main` or `master`; PR builds use `pr-<number>`. |
| `container.release` | `boolean` | `false` | Push images on release/tag workflows. | Enables publish behavior in release flows. |
| `container.build-args` | `array<string>` | `[]` | Build arguments passed to the container build command. | Adds `--build-arg` inputs to the build. |
| `container.args` | `array<string>` | `[]` | Extra build command arguments. | Appends additional arguments such as `--platform` or `--pull`. |
| `container.context` | `string` | empty string | Build context subdirectory, relative to repo root. Allowed characters: letters, numbers, `.`, `_`, `-`, `/`. | Uses the given subdirectory as the build context. |
| `container.dockerfile` | `string` | `Dockerfile` | Dockerfile path. Supported by the runtime even though it is not declared in the schema. | Selects the Dockerfile used for the build. |

```yaml
    container:
      username: quay-user
      password: <quay-token>
      repository: quay.io/example/my-image
      tag: latest
      release: true
      build-args:
        - VERSION=1.2.3
      args:
        - --platform=linux/amd64
      context: src/app
      dockerfile: Dockerfile
```

#### `container.oci-annotations`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `container.oci-annotations.enabled` | `boolean` | `false` | Turn OCI annotations on. | Adds OCI annotations to built images. |
| `container.oci-annotations.static.<key>` | `string` | unset | Static annotation value. Use reverse-domain keys such as `org.opencontainers.image.vendor`. | Adds fixed annotations to every built image. |
| `container.oci-annotations.auto.created` | `boolean` | `true` | Auto-populate `org.opencontainers.image.created`. | Adds the build timestamp annotation. |
| `container.oci-annotations.auto.source` | `boolean` | `true` | Auto-populate `org.opencontainers.image.source`. | Adds the source repository URL annotation. |
| `container.oci-annotations.auto.revision` | `boolean` | `true` | Auto-populate `org.opencontainers.image.revision`. | Adds the commit SHA annotation. |
| `container.oci-annotations.auto.version` | `boolean` | `true` | Auto-populate `org.opencontainers.image.version`. | Adds the pushed tag on release builds. |
| `container.oci-annotations.auto.title` | `boolean` | `true` | Auto-populate `org.opencontainers.image.title`. | Adds the repository name annotation. |

> **Warning:** `container.context` must stay under the repository root. Path traversal is rejected at runtime.

```yaml
    container:
      username: quay-user
      password: <quay-token>
      repository: quay.io/example/my-image
      oci-annotations:
        enabled: true
        static:
          org.opencontainers.image.vendor: Example Corp
        auto:
          created: true
          source: true
          revision: true
          version: true
          title: true
```

### `custom-check-runs`

Where: `Repo/local`

| Key | Type | Default | Description | Effect |
|---|---|---|---|---|
| `custom-check-runs[].name` | `string` | none | Check run name. Required. Use only `A-Z`, `a-z`, `0-9`, `.`, `_`, `-`, maximum length `64`. | Creates a GitHub check run with this exact name and exposes `/retest <name>`. |
| `custom-check-runs[].command` | `string` | none | Shell command to run in the repository worktree. Required. Leading `VAR=value` assignments are allowed. | Executes the custom check command for PR workflows. |
| `custom-check-runs[].mandatory` | `boolean` | `true` | Whether this custom check is required for merge. | Mandatory checks join the required-check list; optional checks still run but do not block merge. |

> **Warning:** Custom check names must be unique and cannot collide with built-in check names: `tox`, `pre-commit`, `build-container`, `python-module-install`, `conventional-title`, `can-be-merged`, `security-suspicious-paths`, and `security-committer-identity`.


> **Note:** The server validates that the command has an executable after any leading environment assignments, and that the executable exists on the server.

```yaml
    custom-check-runs:
      - name: lint
        command: uv tool run --from ruff ruff check
        mandatory: true
      - name: security-scan
        command: TOKEN=value uv tool run --from bandit bandit -r .
        mandatory: false
```

## Related Pages

- [Configure Repositories](configure-repositories.html)
- [Set Up Checks and Release Workflows](set-up-checks-and-release-workflows.html)
- [Enable AI Features](enable-ai-features.html)
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html)
- [Environment Variables](environment-variables.html)


---

Source: environment-variables.md

# Environment Variables

The server reads the variables below at startup. Anything not listed here comes from
`config.yaml` in the data directory, not from the process environment.

## Reference

| Variable | Required? | Default | Purpose |
| --- | --- | --- | --- |
| `WEBHOOK_SERVER_DATA_DIR` | Recommended | `/home/podman/data` | Directory holding `config.yaml`, logs, and the webhook key. |
| `ENABLE_LOG_SERVER` | No | `false` | Serve the log viewer UI, log API, and log WebSocket. |
| `ENABLE_MCP_SERVER` | No | `false` | Mount the MCP endpoint at `/mcp`. |
| `WEBHOOK_SERVER_DEV_MODE` | No | unset (production) | Run uvicorn with reload and a single worker. |
| `SIDECAR_PORT` | No | `9100` | Port for the Pi SDK sidecar started by `entrypoint.sh`. |
| `SERVER_PORT` | Test-only | none | Local port the E2E smee client forwards webhooks to. |
| `SMEE_URL` | Test-only | none | Smee.io channel URL used to receive GitHub webhooks in E2E tests. |
| `TEST_REPO` | Test-only | none | `owner/repo-name` that E2E tests clone and open PRs against. |
| `DOCKER_COMPOSE_FILE` | Test-only | none | Compose file E2E tests bring the server up with. |
| `PYTEST_TIMEOUT` | Test-only | `60` (seconds) | Timeout baseline for the test session. |

## `WEBHOOK_SERVER_DATA_DIR`

Read once in `webhook_server/libs/config.py`. The directory must contain `config.yaml`;
if the file is missing, `Config` raises `FileNotFoundError` on startup. The log viewer and
the MCP log file are written under the same directory, so pointing this at a read-only
path breaks both. The container image creates `/home/podman/data`, which is why that path
is the default.

```bash
    export WEBHOOK_SERVER_DATA_DIR="$HOME/webhook-server-data"
    mkdir -p "$WEBHOOK_SERVER_DATA_DIR"

    cat > "$WEBHOOK_SERVER_DATA_DIR/config.yaml" <<'YAML'
    github-app-id: 123456
    github-tokens:
      - ghp_your_token_here

    webhook-ip: https://your-domain.example/webhook_server

    repositories:
      your-repo:
        name: your-org/your-repo
    YAML

    WEBHOOK_SERVER_DATA_DIR="$WEBHOOK_SERVER_DATA_DIR" uv run entrypoint.py
```

Without `config.yaml` the startup call to `Config.exists()` raises `FileNotFoundError`, so the
directory alone is not enough. See [Quick Start](quick-start.html) for the full setup,
including the GitHub App private key.

## `ENABLE_LOG_SERVER`

Checked as an exact string match against `"true"`, so `1`, `TRUE`, and `yes` all count as
disabled. When it is off, the log viewer routes are never registered and the log WebSocket
closes with a policy-violation code. When it is on, `/logs` and the log API endpoints are
added, including the endpoints that also require the caller to be on a trusted network.

```bash
    ENABLE_LOG_SERVER=true WEBHOOK_SERVER_DATA_DIR="$HOME/webhook-server-data" uv run entrypoint.py
```

## `ENABLE_MCP_SERVER`

Same exact `"true"` comparison as `ENABLE_LOG_SERVER`. When enabled, the app initializes
FastApiMCP and mounts the streamable-HTTP handler at `/mcp`, and MCP log lines are routed to
the file named by `mcp-log-file` in `config.yaml` (default `mcp_server.log`) instead of the
main webhook log. If FastApiMCP fails to import, `/mcp` is still registered and answers
with a 500 rather than a 404, so the failure is visible.

## `WEBHOOK_SERVER_DEV_MODE`

Accepted values are `1`, `true`, and `yes`, case-insensitive (`entrypoint.py`). It sets
uvicorn `reload=True` and omits `workers`, so the process stays single-worker with the
reloader in front. In production mode the app starts with `workers=10` instead.

## `SIDECAR_PORT`

`entrypoint.sh` exports `9100` unless the value is already set, then waits up to 15 seconds
for `http://127.0.0.1:$SIDECAR_PORT/health` before starting the app. The container health
check probes the same URL, so overriding the port without updating the health check will
report the container unhealthy.

## Test-only variables

These four are consumed by the end-to-end fixtures in `webhook_server/tests/e2e/conftest.py`.
The fixture first looks for `.dev/.env`; if the file is missing the run aborts, and each
missing variable raises `E2EInfrastructureError` with the line to add. They have no effect
on the running server.

- `SERVER_PORT` — port the local smee client forwards to; must match the host-side port
  published by the compose file.
- `SMEE_URL` — the smee.io channel URL, for example `https://smee.io/abc123def456`. Tests
  also use it to find and delete the GitHub webhook at teardown.
- `TEST_REPO` — `owner/repo-name`. Cloned over SSH, so the clone key must have access.
- `DOCKER_COMPOSE_FILE` — path to the compose file, absolute or relative to the project root.
  A relative path that does not exist fails the fixture before any container starts.

`PYTEST_TIMEOUT` is also test-only. The autouse fixture in
`webhook_server/tests/conftest.py` reads the current value (falling back to `60`), sets it to
`30` for the duration of each test, and restores the original value afterwards.

Example `.dev/.env`:

```bash
    SERVER_PORT=19876
    SMEE_URL=https://smee.io/abc123def456
    TEST_REPO=owner/repo-name
    DOCKER_COMPOSE_FILE=.dev/docker-compose.yaml
```


---

Source: webhook-and-health-api.md

# Webhook and Health API

The always-available surface is small: a health probe, the GitHub webhook receiver, the `/static` assets used by the log viewer UI, and FastAPI's own `/docs` and `/redoc`. The log viewer API and WebSocket routes are always *registered* too — `ENABLE_LOG_SERVER` gates *access* at request time rather than registration, so with the flag off `/logs/api/*` answers `404` and `/logs/ws` closes the connection. The log viewer HTML page and `/mcp` are the two routes that are only registered when their feature flag is set.

| Method | Path | Purpose | Registered when |
| --- | --- | --- | --- |
| `GET` | `/webhook_server/healthcheck` | Liveness probe | Always |
| `POST` | `/webhook_server` | GitHub webhook receiver | Always |
| `GET` | `/static/*` | CSS/JS assets for the log viewer UI | Always (mounted directory) |
| `GET` | `/logs` | Log viewer UI page | `ENABLE_LOG_SERVER=true` |
| `GET` | `/logs/api/*` | Log viewer API | Always (access gated at request time by `ENABLE_LOG_SERVER`) |
| `WS` | `/logs/ws` | Real-time log stream | Always (closes the connection unless `ENABLE_LOG_SERVER=true`) |
| `GET` | `/docs`, `/redoc` | FastAPI-generated interactive API docs | Always |
| `GET`/`POST`/`DELETE` | `/mcp` | MCP streamable HTTP transport | `ENABLE_MCP_SERVER=true` |

The base path is not configurable. In `webhook_server/app.py`:

```python
    APP_URL_ROOT_PATH: str = "/webhook_server"
```

`GET` uses an f-string on that constant, so it resolves to `/webhook_server/healthcheck`:

```python
    @FASTAPI_APP.get(f"{APP_URL_ROOT_PATH}/healthcheck", operation_id="healthcheck")
```

`POST` passes the constant itself — **no** trailing segment:

```python
    @FASTAPI_APP.post(
        APP_URL_ROOT_PATH,          # -> POST /webhook_server
        operation_id="process_webhook",
        dependencies=[Depends(gate_by_allowlist_ips_dependency)],
        tags=["mcp_exclude"],
    )
```

Because the callback path is fixed, `webhook-ip` in `config.yaml` must be the full URL including that path. The server creates the GitHub hook with that exact value and `content_type: "json"`:

```yaml
    webhook-ip: https://hooks.example.com/webhook_server
```

The server binds `0.0.0.0:5000` by default (`ip-bind` and `port` in `config.yaml`; see `entrypoint.py`).

## Healthcheck

```http
    GET /webhook_server/healthcheck
```

The handler is synchronous and touches no config, no database, and no GitHub API. It always returns `200` with a fixed body:

```json
    {
      "status": 200,
      "message": "Alive"
    }
```

`status` is `requests.codes.ok` (the integer `200`), not a string. Use it as a container liveness/readiness probe; it does **not** verify GitHub credentials or webhook reachability.

```bash
    curl -s http://localhost:5000/webhook_server/healthcheck
```

## Webhook receive

```http
    POST /webhook_server
    Content-Type: application/json
    X-GitHub-Event: pull_request
    X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
    X-Hub-Signature-256: sha256=6f1ed002ab5595859014ebf0951522d9a...
```

`POST /webhook_server` is the GitHub callback. The handler validates synchronously and then returns immediately, with the real work running as an `asyncio` background task.

### Request headers

| Header | Required | Used for |
| --- | --- | --- |
| `X-GitHub-Event` | Yes | Event routing. Missing → `400`. |
| `X-GitHub-Delivery` | No | Delivery ID. Defaults to `"unknown-delivery"`; echoed in the response and used to correlate logs. |
| `X-Hub-Signature-256` | Only when `webhook-secret` is set | `sha256=` HMAC verification. Missing or mismatched → `403`. |
| `Content-Type` | — | Body is read raw as bytes for signature verification, then parsed as JSON. |

Only `X-Hub-Signature-256` is checked. The legacy SHA-1 `X-Hub-Signature` header is ignored.

### Request body

Any GitHub event payload, but the following must be present or the request is rejected with `400`:

- `repository`
- `repository.name`
- `repository.full_name`

```json
    {
      "action": "opened",
      "repository": {
        "name": "github-webhook-server",
        "full_name": "myakove/github-webhook-server"
      },
      "sender": { "login": "myakove" }
    }
```

### Success response

```http
    HTTP/1.1 200 OK
    content-type: application/json
```

```json
    {
      "status": 200,
      "message": "Webhook queued for processing",
      "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
      "event_type": "pull_request"
    }
```

`delivery_id` is the `X-GitHub-Delivery` value (or `"unknown-delivery"`), so it can be used directly against the log viewer.

> **Important:** `200` means *queued*, not *processed*. The server answers as soon as validation passes (GitHub times webhooks out at 10 seconds, while processing typically takes 5–30 seconds). Config lookup, repository validation, GitHub API calls, and all handlers run in the background.

### Background processing

`process_webhook` builds a structured context, constructs `GithubWebhook`, calls `await api.process()`, and always calls `await api.cleanup()`. Errors are caught and logged; **none of them change the HTTP response**:

| Background failure | Where it shows up |
| --- | --- |
| `RepositoryNotFoundInConfigError` | Error log, plus a structured log entry with `success: false` |
| `httpx.ConnectError` / `httpx.RequestError` / `requests.ConnectionError` | Error log with traceback, structured log entry with `success: false` |
| Any other exception | Error log with traceback, structured log entry with `success: false` |
| `asyncio.CancelledError` | Re-raised (shutdown), not logged as an error |

Each run writes a structured log record (see [Debug with the Log Viewer](debug-with-the-log-viewer.html)) and logs a summary with the delivery ID prefix. During shutdown the server waits up to 30 seconds for in-flight background tasks, then cancels them.

### Error responses

All errors are FastAPI's standard JSON error shape.

| Status | `detail` | Cause |
| --- | --- | --- |
| `400` | `Missing X-GitHub-Event header` | No `X-GitHub-Event` header |
| `400` | `Failed to read request body` | Body could not be read |
| `400` | `Invalid JSON payload` | Body is not valid JSON |
| `400` | `Missing repository in payload` | No `repository` key |
| `400` | `Missing repository.name in payload` | No `repository.name` |
| `400` | `Missing repository.full_name in payload` | No `repository.full_name` |
| `400` | `Could not determine client IP address` | IP allowlist enabled, no client IP available |
| `400` | `Could not parse client IP address` | IP allowlist enabled, client IP unparseable |
| `403` | `x-hub-signature-256 header is missing!` | `webhook-secret` set, signature header absent |
| `403` | `Request signatures didn't match!` | HMAC mismatch (bad secret or tampered body) |
| `403` | `<ip> IP is not a valid ip in allowlist IPs` | Source IP not in the allowlist |
| `500` | `Configuration error` | Failure while loading config for signature verification |

```json
    { "detail": "Request signatures didn't match!" }
```

Note: signature verification failures return `403`, not `401` (verified in `webhook_server/utils/app_utils.py` and by `test_process_webhook_signature_verification_failure`).

## Authentication

Two independent layers gate `POST /webhook_server`.

### 1. HMAC signature (secret)

Set in the root `config.yaml`:

```yaml
    webhook-secret: your-random-secret-string
```

At startup, `entrypoint.py` reads `webhook-secret` and passes it to `repository_and_webhook_settings(webhook_secret=...)`, which forwards it to `create_webhook` → `process_github_webhook`. The hook is created/updated with `config = {"url": webhook-ip, "content_type": "json", "secret": secret}`. If an existing hook's URL matches but its secret presence differs from the configured value, the old hook is deleted and recreated so the secret always matches.

Per request, `verify_signature` computes:

```python
    hash_object = hmac.new(secret_token.encode("utf-8"), msg=payload_body, digestmod=hashlib.sha256)
    expected_signature = "sha256=" + hash_object.hexdigest()
    hmac.compare_digest(expected_signature, signature_header)  # constant-time compare
```

The signature is computed over the **raw request bytes**, so any proxy that re-encodes JSON (pretty-printing, reordering keys) breaks verification. Proxies must forward the body untouched and pass `X-Hub-Signature-256` through.

If `webhook-secret` is **not** set, the signature step is skipped entirely and any caller that can reach the port can post a payload. See [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html).

### 2. Source-IP allowlist

Set in the root `config.yaml`:

```yaml
    verify-github-ips: true
    # verify-cloudflare-ips: true
```

During lifespan startup the server fetches the published CIDR ranges (GitHub meta API and/or the Cloudflare list) and builds a network tuple. `POST /webhook_server` runs `gate_by_allowlist_ips_dependency` → `gate_by_allowlist_ips(request, ALLOWED_IPS)` before the handler body:

- allowlist empty → all source IPs accepted (verification off)
- client IP inside any allowlisted network → accepted
- otherwise → `403`

If verification is enabled but no valid ranges load, startup raises `RuntimeError` and the server refuses to start rather than accepting everything. If one source fails but the other succeeds, the server logs the error and continues with what loaded.

The check uses the client IP the app sees. Behind another proxy or load balancer you would be validating that proxy, not GitHub — terminate TLS in front of the server and forward the real address, or verify at the proxy.

## Other route groups (summary)

Full reference: [Log Viewer and MCP API](log-viewer-and-mcp-api.html) and [Debug with the Log Viewer](debug-with-the-log-viewer.html).

### Log viewer — `ENABLE_LOG_SERVER=true`

| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/logs` | HTML viewer page. Registered only inside the `if LOG_SERVER_ENABLED:` block. |
| `GET` | `/logs/api/entries` | Filtered, paginated log search (`limit` 1–10000, default 100; `offset` ≥ 0). `404` if the log server is disabled. |
| `GET` | `/logs/api/export` | Log export; `format_type` must match `^json$` (default `json`). |
| `GET` | `/logs/api/pr-flow/{hook_id}` | PR workflow visualization for one delivery. |
| `GET` | `/logs/api/workflow-steps/{hook_id}` | Per-step timeline for one delivery. |
| `GET` | `/logs/api/step-logs/{hook_id}/{step_name}` | Log entries correlated to one step. Adds a trusted-network check: private, loopback, or link-local client IPs only, else `403`. |
| `WS` | `/logs/ws` | Real-time log stream with the same filters as the UI. Closes with `1008` when the log server is disabled. |

Every `/logs/api/*` route depends on `require_log_server_enabled`, which returns `404` with `Log server is disabled. Set ENABLE_LOG_SERVER=true to enable.` when the flag is off.

### MCP — `ENABLE_MCP_SERVER=true`

```python
    FASTAPI_APP.add_api_route(
        "/mcp",
        handle_mcp_streamable_http,
        methods=["GET", "POST", "DELETE"],
        include_in_schema=False,
        operation_id="mcp_http",
    )
```

Streamable HTTP transport, stateless (`stateless=True`, `json_response=True`), so no session handshake is needed. The route is registered even if the `fastapi_mcp` import failed, so a broken MCP install yields `500` rather than `404`. Routes tagged `mcp_exclude` — which includes `POST /webhook_server` — are not exposed as MCP tools. **No authentication is configured**: deploy on a trusted network or behind an authenticating reverse proxy.

MCP import and session-manager setup are lazy and failure-tolerant: a failed import logs and continues without MCP, and a failed session manager is torn down while the app keeps serving webhooks.

### Static assets

`FASTAPI_APP.mount("/static", StaticFiles(directory="webhook_server/web/static"))` serves the log viewer CSS/JS. Startup fails fast if that directory is missing or is not a directory.

## Full request walkthrough

```bash
    # The signing key is whatever you set as webhook-secret in config.yaml.
    HMAC_KEY="paste-your-webhook-secret-here"
    PAYLOAD='{"action":"opened","repository":{"name":"github-webhook-server","full_name":"myakove/github-webhook-server"},"sender":{"login":"myakove"}}'
    SIGNATURE="sha256=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$HMAC_KEY" | awk '{print $2}')"

    curl -i -X POST http://localhost:5000/webhook_server \
      -H "Content-Type: application/json" \
      -H "X-GitHub-Event: pull_request" \
      -H "X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958" \
      -H "X-Hub-Signature-256: $SIGNATURE" \
      -d "$PAYLOAD"
```

```http
    HTTP/1.1 200 OK

    {"status":200,"message":"Webhook queued for processing","delivery_id":"72d3162e-cc78-11e3-81ab-4c9367dc0958","event_type":"pull_request"}
```

Then follow that `delivery_id` in the log viewer (`hook_id` filter on `/logs/api/entries`) to see whether processing actually succeeded.

For a live OpenAPI schema, `/docs` and `/redoc` are served by FastAPI (title `webhook-server`) with `POST /webhook_server` hidden from the MCP tool surface via its `mcp_exclude` tag.

## Configuration keys this API depends on

| Key | Effect on this API |
| --- | --- |
| `webhook-ip` | Full callback URL, including the `/webhook_server` path. Registered as the hook target. |
| `webhook-secret` | Enables `X-Hub-Signature-256` verification; also set on managed hooks. |
| `verify-github-ips` | Adds GitHub meta CIDRs to the source-IP allowlist. |
| `verify-cloudflare-ips` | Adds Cloudflare CIDRs to the source-IP allowlist. |
| `ip-bind`, `port`, `max-workers` | Server bind address, port (default `5000`), worker count. |
| `logs-server-log-file`, `mcp-log-file` | Log destinations for the log viewer and MCP server. |

See [Configuration Reference](configuration-reference.html) for every key and [Environment Variables](environment-variables.html) for the feature flags.


---

Source: log-viewer-and-mcp-api.md

# Log Viewer and MCP API

Reference for the debugging and AI surfaces of the server: the log viewer HTML page, the `/logs/api/*` JSON routes, the `/logs/ws` live stream, and the `/mcp` Model Context Protocol endpoint.

> **⚠️ Security warning — these endpoints are unauthenticated**
>
> `GET /logs`, every `/logs/api/*` route, the `/logs/ws` WebSocket, and `/mcp` have **no authentication of any kind**. There is no auth middleware, no API key, no bearer token, and no session check anywhere in `webhook_server/app.py`. Anyone who can reach the port can read your webhook logs, which contain repository names, PR numbers, GitHub usernames, delivery IDs, error text, and stack traces.
>
> - **Never expose these paths to the public internet.** No port-forwarding them to a public host, no public load balancer in front of them, no tunnelling service.
> - Deploy on a **trusted network only** — localhost, an internal network, or a VPN.
> - If remote access is required, front the server with a **reverse proxy that adds authentication** (Basic auth, mTLS, oauth2-proxy, an identity-aware proxy) and only allow it to reach `/logs*` and `/mcp`.
> - This is the same rule the project's own `AGENTS.md` states: *"NEVER expose log viewer (`/logs/*`) to public internet — endpoints are unauthenticated; deploy on trusted networks only."*

One route, `GET /logs/api/step-logs/{hook_id}/{step_name}`, additionally requires a **private, loopback, or link-local client IP** via `require_trusted_network`. That is a network-range check on `request.client.host`, not authentication, and it is trivially bypassed by a misconfigured reverse proxy that does not preserve the client address. It is a speed bump, not a control.

## Route summary

| Method | Path | Purpose | Registered when |
| --- | --- | --- | --- |
| `GET` | `/logs` | Log viewer HTML page | `ENABLE_LOG_SERVER=true` |
| `GET` | `/logs/api/entries` | Filtered, paginated log search | Always; returns `404` if disabled |
| `GET` | `/logs/api/export` | Streamed JSON download of filtered logs | Always; returns `404` if disabled |
| `GET` | `/logs/api/pr-flow/{hook_id}` | Stage-by-stage PR flow for one delivery | Always; returns `404` if disabled |
| `GET` | `/logs/api/workflow-steps/{hook_id}` | Step timeline for one delivery | Always; returns `404` if disabled |
| `GET` | `/logs/api/step-logs/{hook_id}/{step_name}` | Log lines emitted during one step | Always; `404` if disabled, `403` from a non-private IP |
| `WS` | `/logs/ws` | Real-time log stream | Endpoint always; closes `1008` if disabled |
| `GET`/`POST`/`DELETE` | `/mcp` | MCP streamable HTTP transport | `ENABLE_MCP_SERVER=true` |
| `GET` | `/static/*` | CSS/JS for the viewer page | Always (mounted directory) |
| `POST` | `http://127.0.0.1:5001/tools/run` | Loopback tool server for the AI sidecar | Always (started by `entrypoint.py`) |

The two flags are read once at import time and the routes are then either registered or not:

```python
    LOG_SERVER_ENABLED: bool = os.environ.get("ENABLE_LOG_SERVER") == "true"
    MCP_SERVER_ENABLED: bool = os.environ.get("ENABLE_MCP_SERVER") == "true"
```

The value must be the exact lowercase string `true`. `TRUE`, `1`, `yes`, and an unset variable all leave the feature off.

## Enabling the log viewer

Set the flag and restart the server:

```bash
    ENABLE_LOG_SERVER=true WEBHOOK_SERVER_DATA_DIR=/path/to/data uv run entrypoint.py
```

For the container, add `ENABLE_LOG_SERVER=true` to the container environment and restart it.

Then browse to `http://127.0.0.1:5000/logs` (the default bind is `0.0.0.0:5000` from `ip-bind`/`port` in `config.yaml`; change the port in the URL if you configured a different one).

`GET /logs` only exists inside the feature-flag block, so with the flag off the path is a plain `404` from the router. The `/logs/api/*` routes are always registered but are wrapped in `require_log_server_enabled`, so they answer:

```json
    {"detail": "Log server is disabled. Set ENABLE_LOG_SERVER=true to enable."}
```

with status `404`.

```python
    def require_log_server_enabled() -> None:
        """Dependency to ensure log server is enabled before accessing log viewer APIs."""
        if not LOG_SERVER_ENABLED:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Log server is disabled. Set ENABLE_LOG_SERVER=true to enable.",
            )
```

### What the page serves

`LogViewerController.get_log_page()` reads `webhook_server/web/templates/log_viewer.html` and returns it as `HTMLResponse`. If the template cannot be read, the controller returns an inline fallback error page instead of crashing — you get a `200` page that says *"Log Viewer Template Error"*, not a stack trace. Any other failure becomes `500 Internal server error`.

The controller is a process-wide singleton (`get_log_viewer_controller()`), created on first use with a logger writing to the file named by `logs-server-log-file` (default `logs_server.log`).

### Where the data comes from

Everything is read from disk, under `<data-dir>/logs`:

- `webhooks_YYYY-MM-DD.json` — JSONL, one object per line: either `type: "webhook_summary"` (the complete `WebhookContext` for one delivery) or `type: "log_entry"` (an individual log line). Written by `write_webhook_log()` in `webhook_server/utils/structured_logger.py`.
- `*.log` and rotated `*.log.*` — the human-readable text logs produced by `get_logger_with_params()`.

Reads are streamed, never slurped: `_stream_log_entries()` reads at most 25 files (JSON webhook files first, then by mtime newest-first) and caps at 20 000 entries for unfiltered queries or 50 000 when any filter is set. Infrastructure loggers — `mcp.server.streamable_http`, `logs_server.log`, `log_parser`, `mcp_server.log` — are skipped, because they carry no `hook_id` or `event_type` and would flood unfiltered results.

## `GET /logs/api/entries`

Filtered, paginated search over historical logs. Operation ID `get_log_entries`.

```http
    GET /logs/api/entries?repository=owner/repo&level=ERROR&limit=100&offset=0
```

### Query parameters

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `hook_id` | str | — | GitHub delivery ID (`X-GitHub-Delivery`) |
| `pr_number` | int | — | Pull request number |
| `repository` | str | — | `owner/repo` |
| `event_type` | str | — | e.g. `pull_request`, `push`, `issues`, `issue_comment` |
| `github_user` | str | — | Username who triggered the event |
| `level` | str | — | Exact, case-sensitive match on the entry level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `COMPLETED` for JSON summary entries. Not validated — a typo matches nothing. |
| `start_time` | str | — | ISO 8601, e.g. `2024-01-15T10:00:00Z` |
| `end_time` | str | — | ISO 8601 |
| `search` | str | — | Case-insensitive substring of the log message |
| `limit` | int | `100` | `1`–`10000` (FastAPI `Query`, rejects out-of-range with `422`) |
| `offset` | int | `0` | `>= 0` |

All filters are ANDed and matched exactly against the parsed entry fields; `search` is a case-insensitive substring of `message`. Time filters compare against the entry timestamp — `start_time` keeps entries at or after it, `end_time` keeps entries at or before it. `Z` suffixes are accepted (`parse_datetime_string()` rewrites a trailing `Z` to `+00:00` before calling `datetime.fromisoformat`); an unparseable value is a `400` with `Invalid start_time format: ...`.

### Response

```json
    {
      "entries": [
        {
          "timestamp": "2024-01-15T14:30:25.123456+00:00",
          "level": "INFO",
          "logger_name": "GithubWebhook",
          "message": "owner/repo [pull_request][72d3162e-cc78-11e3-81ab-4c9367dc0958][alice][PR 42]: Processing webhook",
          "hook_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
          "event_type": "pull_request",
          "repository": "owner/repo",
          "pr_number": 42,
          "github_user": "alice",
          "task_id": null,
          "task_type": null,
          "task_status": null,
          "token_spend": null
        }
      ],
      "entries_processed": 1542,
      "filtered_count_min": 100,
      "total_log_count_estimate": "48210",
      "limit": 100,
      "offset": 0,
      "is_partial_scan": false
    }
```

Read the counters before you trust the result set:

- `entries_processed` — entries actually examined. A `"50000+"` string means the streaming cap was hit and more entries exist.
- `is_partial_scan` — `true` when the scan stopped at the cap rather than reading everything.
- `filtered_count_min` — `len(entries) + offset`, the **lower bound** of matches. There is no exact total count; paging with `offset` is the way to walk a large result set.
- `total_log_count_estimate` — string, sampled from file sizes across the first 10 `.log` files (~200 bytes per line assumed). It is a size for the stats bar, not a count you can page against.

Errors: `400` for an unparseable timestamp or a limit outside 1–10000 reaching the controller, `422` from FastAPI for a `limit`/`offset` outside the declared `Query` bounds. Damaged log data is not an error: a log file that cannot be read is logged at `WARNING` and skipped by the streamer, and a JSONL line that is not valid JSON is dropped by `get_raw_json_entry()`. A corrupt file therefore yields fewer entries, not a `500`.

## `GET /logs/api/export`

The same filters, streamed to a file download. Operation ID `export_logs`.

```http
    GET /logs/api/export?format_type=json&level=ERROR&start_time=2024-01-01T00:00:00Z&limit=10000
```

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `format_type` | str | `json` | Constrained by the pattern `^json$`; anything else is `422` |
| `hook_id`, `pr_number`, `repository`, `event_type`, `github_user`, `level`, `start_time`, `end_time`, `search` | — | — | Identical to `/logs/api/entries` |
| `limit` | int | `10000` | `1`–`100000` at the query layer |

The controller rejects `limit > 50000` with `413` ("Result set too large"), even though the query parameter accepts up to 100 000.

Response headers:

```
    content-type: application/json
    content-disposition: attachment; filename=webhook_logs_20240115_143025.json
```

Note the filename pattern: `webhook_logs_%Y%m%d_%H%M%S.json`.

Body:

```json
    {
      "export_metadata": {
        "generated_at": "2024-01-15T14:30:25.123456+00:00",
        "filters_applied": {"repository": "owner/repo", "level": "ERROR", "limit": 10000},
        "total_entries": 156,
        "export_format": "json"
      },
      "log_entries": [ "...LogEntry objects, same shape as /logs/api/entries..." ]
    }
```

`filters_applied` omits keys you did not set. The body is built in memory and yielded as a single chunk through a `StreamingResponse`; it is not incrementally streamed to the client.

Errors: `400` for a non-`json` `format_type` reaching the controller or a bad timestamp, `413` for `limit > 50000`, `422` for a `format_type` that fails the pattern or a `limit` outside `1`–`100000`, `500` on generation failure.

## `GET /logs/api/pr-flow/{hook_id}`

Stage-level flow analysis for one delivery or PR. Operation ID `get_pr_flow_data`.

```http
    GET /logs/api/pr-flow/72d3162e-cc78-11e3-81ab-4c9367dc0958
```

`hook_id` is the only parameter, and it is flexible — the controller branches on the value:

| Form | Interpreted as |
| --- | --- |
| `hook-<id>` | Delivery ID, with the `hook-` prefix stripped |
| `pr-42` | PR number `42` |
| `42` | PR number `42` (bare digits) |
| anything else | Delivery ID as given |

Entries are streamed from at most 15 files / 10 000 entries and filtered by whichever identifier was recognised. Stages are then matched by regex over the log messages (`WORKFLOW_STAGE_PATTERNS`): Webhook Received, Validation Complete, Reviewers Assigned, Labels Applied, Checks Started, Checks Complete, Processing Complete.

```json
    {
      "identifier": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
      "stages": [
        {"name": "Webhook Received", "timestamp": "2024-01-15T14:30:25.123456+00:00", "duration_ms": null},
        {"name": "Checks Complete", "timestamp": "2024-01-15T14:31:02.500000+00:00", "error": "build failed"}
      ],
      "total_duration_ms": 37376,
      "success": false,
      "error": "build failed"
    }
```

Each stage carries `name`, `timestamp`, `duration_ms` (time since the previous matched stage; `null` on the first stage), and `error` when the matched entry was at `ERROR` level. `error` at the top level is the first error message found.

Errors: `404` when no log entries match, `400` when `pr-<x>` is not a valid integer, `500` on parse failure.

## `GET /logs/api/workflow-steps/{hook_id}`

The step timeline behind the viewer's flow modal. Operation ID `get_workflow_steps`.

```http
    GET /logs/api/workflow-steps/72d3162e-cc78-11e3-81ab-4c9367dc0958
```

Only parameter: `hook_id`, the delivery ID.

Data source, in order:

1. **JSON summaries first.** `_stream_json_log_entries()` scans `webhooks_*.json` for a `type: "webhook_summary"` entry whose `hook_id` matches. That entry is the serialized `WebhookContext` (see [context](#hook-id-and-the-workflow-context)), and `_transform_json_entry_to_timeline()` reshapes it.
2. **Text log fallback.** If no JSON entry matches — or the JSON path raises **any** `HTTPException`, including `500 Malformed log entry` — `get_workflow_steps()` re-reads the text `.log` files and reconstructs the timeline from `logger.step` lines. This path also recovers `token_spend`, falling back to parsing it out of the message text.

Response from the JSON path:

```json
    {
      "hook_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958",
      "start_time": "2024-01-15T14:30:25.123456+00:00",
      "total_duration_ms": 45230,
      "step_count": 12,
      "steps": [
        {
          "step_name": "webhook_routing",
          "timestamp": "2024-01-15T14:30:25.156789+00:00",
          "duration_ms": 25001,
          "status": "completed",
          "error": null
        }
      ],
      "token_spend": 5,
      "event_type": "check_run",
      "action": "created",
      "repository": "owner/repo",
      "sender": "alice",
      "pr": {"number": 225},
      "success": true,
      "error": null
    }
```

`pr` is `null` when the delivery had no pull request. `token_spend` is only present when the delivery recorded one.

A broken summary never yields partial data here — but it also never surfaces as a `500`. Three cases, and they are not the same:

| Situation | JSON path | What the endpoint returns |
| --- | --- | --- |
| The summary line is not valid JSON | `get_raw_json_entry()` catches `json.JSONDecodeError` and returns `None`; the line is dropped silently, no error raised | Behaves exactly as if no summary existed: `404` from the JSON path, then the text fallback |
| No `webhook_summary` entry for the `hook_id` inside the scanned window | `404 No JSON log entry found for hook ID: ...` | Text fallback |
| The summary parses but fails structural validation — missing `timing`, missing `timing.started_at` or `timing.duration_ms`, missing `workflow_steps`, a non-dict `pr`, or a step without a `timestamp` | `_transform_json_entry_to_timeline()` raises `ValueError`; `get_workflow_steps_json()` converts it to `500 Malformed log entry` | That `500` is **swallowed** and the request falls through to the text logs. You get a reconstructed `200` timeline in the text-fallback shape, or `404 No data found for hook ID: ...` / `404 No workflow steps found for hook ID: ...` if the text logs do not cover the delivery |

The swallow is the backward-compatibility path, and it is unconditional — it does not distinguish a missing summary from a corrupt one:

```python
    try:
        # First try JSON logs (more efficient and complete)
        try:
            return await self.get_workflow_steps_json(hook_id)
        except HTTPException:
            # Fall back to text log parsing for backward compatibility
            pass
```

So `500 Malformed log entry` is **not** a response this endpoint can produce. It belongs to `get_workflow_steps_json()` and is only reachable through `/logs/api/step-logs/{hook_id}/{step_name}`, which calls that method directly. To diagnose a corrupt summary, look for `Malformed log entry for hook ID: ...` in `logs_server.log` — the `logger.exception()` fires even though the request does not fail.

The text-fallback shape differs: steps carry `message`, `level`, `relative_time_ms`, `repository`, `event_type`, `pr_number`, `task_id`, `task_type`, `task_status` instead of the JSON-path fields, and the top level has only `hook_id`, `start_time`, `total_duration_ms`, `step_count`, `steps` (plus `token_spend` when found).

Errors: `404` when neither path finds data for the ID — `No data found for hook ID: ...` when the text logs hold nothing for it, `No workflow steps found for hook ID: ...` when they hold entries but no `logger.step` lines; `400` for an unusable identifier; `500 Internal server error` for an unexpected failure. A malformed JSON summary does **not** produce a `500` here — see the table above.

## `GET /logs/api/step-logs/{hook_id}/{step_name}`

The log lines written while one workflow step ran. Operation ID `get_step_logs`. This is the one route with a network restriction.

```http
    GET /logs/api/step-logs/72d3162e-cc78-11e3-81ab-4c9367dc0958/webhook_routing
```

| Parameter | In | Constraints |
| --- | --- | --- |
| `hook_id` | path | 1–100 characters |
| `step_name` | path | 1–100 characters — must equal a `step_name` from the timeline above, e.g. `webhook_routing`, `clone_repository` |

The step is located in the workflow timeline, then a time window is computed: `step.timestamp` to `step.timestamp + duration_ms`, or a 60 000 ms default window when the step has no recorded duration. Text `.log` entries with the same `hook_id` inside that window are collected, capped at **500 entries** (`_MAX_STEP_LOGS`).

```json
    {
      "step": {
        "name": "webhook_routing",
        "status": "completed",
        "timestamp": "2024-01-15T14:30:25.156789+00:00",
        "duration_ms": 25001,
        "error": null
      },
      "logs": [ "...LogEntry objects..." ],
      "log_count": 42
    }
```

This route reads the JSON summaries only. It calls `get_workflow_steps_json()` directly, with no text-log fallback, which makes it the one endpoint that can actually return `500 Malformed log entry`: a matching summary that fails structural validation propagates the error instead of being abandoned. A `hook_id` with no summary at all gets `404 No JSON log entry found for hook ID: ...` — note that is *not* the same message the workflow-steps route returns, and that route may still serve the delivery from text logs.

Errors: `404` if no JSON summary matches the hook ID or the step name is not in the timeline, `403` when the client IP is not private/loopback/link-local or cannot be determined, `500 Malformed log entry` for a summary that parses but fails validation, `500` if the step has no usable timestamp or its timestamp is unparseable.

### The trusted-network check

```python
    async def require_trusted_network(request: Request) -> None:
        ...
        is_trusted = client_ip.is_private or client_ip.is_loopback or client_ip.is_link_local
        if not is_trusted:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied: ...")
```

The code's own docstring says it plainly: this check *"can be bypassed if the server is behind a reverse proxy that doesn't properly set X-Forwarded-For headers."* Treat it as defence in depth, not as the reason you can expose the server.

## `WS /logs/ws`

Real-time streaming of new log lines as they are written.

```javascript
    const ws = new WebSocket(`ws://${window.location.host}/logs/ws?hook_id=72d3162e-cc78-11e3-81ab-4c9367dc0958&level=ERROR`);
    ws.onmessage = (event) => console.log(JSON.parse(event.data));
```

| Parameter | Type | Notes |
| --- | --- | --- |
| `hook_id` | str | Delivery ID |
| `pr_number` | int | PR number |
| `repository` | str | `owner/repo` |
| `event_type` | str | GitHub event type |
| `github_user` | str | Triggering username |
| `level` | str | Log level |

These are query parameters, not message frames. There is no `start_time`, `end_time`, or `search` on the socket — the live stream only supports the six filters above, unlike the REST search.

Behaviour:

- The handler checks `LOG_SERVER_ENABLED` itself, because WebSocket routes do not take FastAPI dependencies. If disabled, the connection is closed with code **1008** and reason `"Log server is disabled"`.
- Otherwise the socket is accepted, added to the controller's connection set, and `LogParser.monitor_log_directory()` tails the log directory.
- Each new entry is pushed as a single JSON object with the same `LogEntry.to_dict()` shape used by the REST routes.
- With no filters, every entry is sent. With any filter set, `LogFilter.filter_entries()` decides per entry.
- If the log directory does not exist, the server sends `{"error": "Log directory not found"}` and returns.
- On an internal error the server closes with code **1011**. On shutdown the controller closes all open sockets with **1001** (`Server shutdown`) via `LogViewerController.shutdown()`.
- Client disconnects (`WebSocketDisconnect`) are normal and logged at `INFO`; the socket is removed from the connection set in a `finally` block.

## MCP server

Enabled with `ENABLE_MCP_SERVER=true`. One route, registered by hand:

```python
    FASTAPI_APP.add_api_route(
        "/mcp",
        handle_mcp_streamable_http,
        methods=["GET", "POST", "DELETE"],
        include_in_schema=False,
        operation_id="mcp_http",
    )
```

`include_in_schema=False` keeps the endpoint out of `/openapi.json`, `/docs`, and the MCP tool list.

### Setup sequence

1. `_initialize_mcp(FASTAPI_APP)` runs at import time, but every import is lazy — `fastapi_mcp`, `fastapi_mcp.transport.http`, and `mcp.server.streamable_http_manager`. A missing dependency logs `Failed to initialize MCP server; continuing without MCP` and leaves the globals as `None`; webhook processing is never blocked by a broken MCP install.
2. `FastApiMCP(app, exclude_tags=["mcp_exclude"])` wraps the FastAPI app. Because this runs at the end of `app.py`, the routes registered above it are the ones that get wrapped.
3. The HTTP transport is `FastApiHttpSessionManager(mcp_server=mcp.server, event_store=None, json_response=True)` — **stateless**, no event store, JSON responses. There is no session handshake.
4. The `/mcp` route is registered **even if the above failed**, so a broken MCP install answers `500` (`MCP server not initialized`) rather than a misleading `404`.
5. The real `StreamableHTTPSessionManager` is created during the FastAPI lifespan, with `stateless=True`, and run as a background task tracked in `_background_tasks`. A failure there logs, tears the manager down, and leaves the rest of the app running.

### Exposed tools

`FastApiMCP` converts every OpenAPI operation into a tool named after its `operation_id`, then drops anything tagged `mcp_exclude`. With both flags on, the tool list is:

| Tool | Backing route |
| --- | --- |
| `healthcheck` | `GET /webhook_server/healthcheck` |
| `get_log_viewer_page` | `GET /logs` (present only when `ENABLE_LOG_SERVER=true`) |
| `get_log_entries` | `GET /logs/api/entries` |
| `export_logs` | `GET /logs/api/export` |
| `get_pr_flow_data` | `GET /logs/api/pr-flow/{hook_id}` |
| `get_workflow_steps` | `GET /logs/api/workflow-steps/{hook_id}` |
| `get_step_logs` | `GET /logs/api/step-logs/{hook_id}/{step_name}` |

`POST /webhook_server` carries `tags=["mcp_exclude"]` and is therefore **not** exposed as a tool — an AI client cannot post webhooks through MCP. `/mcp` itself is excluded by `include_in_schema=False`. The `/static` mount and the WebSocket are not OpenAPI operations and are not tools either.

### Client configuration

Because the transport is stateless and JSON-only, point a Streamable HTTP MCP client at the base URL:

```json
    {
      "mcpServers": {
        "github-webhook-server": {
          "type": "http",
          "url": "http://127.0.0.1:5000/mcp"
        }
      }
    }
```

`GET` and `DELETE` are accepted for the session-management verbs, but with `stateless=True` no session state is kept between requests.

> **⚠️ No authentication.** `app.py` says so at the registration site: *"MCP server runs without auth — deploy only on trusted networks (VPN, internal), never expose to public internet - use reverse proxy with auth for external access."* Any MCP client that can reach the port gets the log-reading tools listed above. Same rule as `/logs`: trusted network, or an authenticating reverse proxy.

### Logging

When MCP is enabled, log lines from `mcp.server.streamable_http` are routed to the file named by `mcp-log-file` (default `mcp_server.log`) instead of the main webhook log, by attaching a handler and setting `propagate = False`. A `logging.Filter` suppresses the `ClosedResourceError` "Error in message router" noise that appears when clients disconnect mid-stream.

## Loopback tool server (not FastAPI)

`webhook_server/web/tool_server.py` is a separate `aiohttp` app started by `entrypoint.py` on its own daemon thread and event loop, bound to **`127.0.0.1` only**:

```python
    TOOL_SERVER_PORT = 5001
    ...
    app.router.add_post("/tools/run", handle_tool_request)
    site = web.TCPSite(runner, "127.0.0.1", port)
```

It is the git-tool bridge for AI-assisted conflict resolution (`runner_handler.py` builds `http://127.0.0.1:5001/tools/run` per tool). It is unreachable from other hosts by design; do not change the bind address.

### `POST 127.0.0.1:5001/tools/run`

JSON body:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `tool` | str | Yes | Registry key; unknown key returns `404` listing the valid ones |
| `cwd` | str | Yes | Repository working directory, substituted into `{cwd}` in the command prefix |
| `args` | str | No | Argument string, `shlex.split`; empty is fine |
| `timeout` | int \| null | No | Clamped to 1–300 s; falls back to the tool default (`30`) when unparseable |

Response: `{"success": bool, "output": "..."}` — stdout, or stderr when stdout is empty, truncated to 50 000 characters. A timeout returns `{"success": false, "output": "Command timed out after Ns"}`.

### Tool registry

| Tool | Command | Blocked flags | Success exit codes |
| --- | --- | --- | --- |
| `git_diff` | `git -C {cwd} diff` | `--no-index`, `--output`, `--raw` | `0`, `1` |
| `git_log` | `git -C {cwd} log` | — | `0` |
| `git_show` | `git -C {cwd} show` | — | `0` |
| `git_status` | `git -C {cwd} status` | — | `0` |
| `git_rm` | `git -C {cwd} rm` | `--cached`, `-r`, `-f`, `--force`, `--pathspec-from-file` | `0` |

Blocked flags are rejected with `403`, including combined short options: `-rf` is caught by expanding it into `-r -f` and checking each character against the blocked short flags. This is what keeps the AI conflict-resolution path read-only apart from scoped `git rm` and file edits — no `bash`, no recursive delete.

Status codes: `400` for a non-JSON body, a missing `tool`/`cwd`, or a non-string `cwd`/`args`; `403` for a blocked flag or a disallowed subcommand; `404` for an unknown tool name; `200` for anything the subprocess produced.

## `hook_id` and the workflow context

Everything under `/logs/api/*` that takes a `hook_id` is keyed by the GitHub delivery ID — the `X-GitHub-Delivery` header of the original webhook. Per request the server builds a `WebhookContext` (`webhook_server/utils/context.py`) and, at the end of processing, writes it as one `type: "webhook_summary"` line to `webhooks_YYYY-MM-DD.json`. That summary is what the pr-flow and workflow-steps routes read.

The fields the viewer surfaces:

| Field | Meaning |
| --- | --- |
| `hook_id` | Delivery ID |
| `event_type`, `action` | GitHub event and action |
| `repository`, `repository_full_name` | Target repository |
| `sender`, `api_user` | Who triggered it; which API identity acted |
| `pr` | `{number, title, author}` or `null` |
| `timing.started_at`, `timing.completed_at`, `timing.duration_ms` | Wall-clock timing |
| `workflow_steps` | Map of step name → `{timestamp, status, error, duration_ms}` |
| `token_spend` | GitHub API calls consumed by this delivery |
| `initial_rate_limit`, `final_rate_limit` | Rate limit before and after |
| `success`, `error` | Overall outcome and top-level error with traceback |
| `summary` | One-line human-readable result |

Steps are recorded through `start_step()` / `complete_step()` / `fail_step()`. A step that started but never completed is marked `status: "started"` with `duration_ms: null` in the summary line, which the timeline reads as "incomplete".

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| `/logs` is `404` | `ENABLE_LOG_SERVER` is not exactly `true`, or the server was not restarted after setting it |
| `/logs/api/*` returns `{"detail": "Log server is disabled..."}` | Flag off — the routes exist but the dependency rejects them |
| `/logs/api/step-logs/...` returns `403` | Client IP is not private/loopback/link-local, or the proxy is rewriting the client address |
| The viewer page renders "Log Viewer Template Error" | `templates/log_viewer.html` could not be read; a built-in fallback page is served |
| The page loads but shows no entries | `<data-dir>/logs` is empty or missing. Startup fails if `webhook_server/web/static/` is missing, not the logs directory — check `WEBHOOK_SERVER_DATA_DIR`. |
| `is_partial_scan: true` or a `"...+"` count | The 20 000 / 50 000 entry streaming cap was hit. Add `hook_id`, `repository`, or a time range. |
| `404` from `workflow-steps` for a delivery you can see in `/logs/api/entries` | The JSON summary has not been written yet (it is written at the end of processing), the delivery fell outside the 25-file / 50 000-entry window, or the summary is corrupt and the text logs have no `logger.step` lines to replace it — all three end in the same `404` |
| `500 Malformed log entry` from `step-logs` but a `200` (or `404`) from `workflow-steps` for the same ID | The two routes disagree by design: `step-logs` reads the JSON summary directly and surfaces the validation error, `workflow-steps` falls back to text logs. Check `logs_server.log` for `Malformed log entry for hook ID: ...` |
| `/mcp` returns `500` "MCP server not initialized" | `fastapi_mcp` failed to import or the session manager failed to start. Check `mcp_server.log` and the startup logs. |

## See also

- [Debug with the Log Viewer](debug-with-the-log-viewer.html) — task walkthrough for the UI.
- [Webhook and Health API](webhook-and-health-api.html) — the always-on public surface and the IP allowlist.
- [Environment Variables](environment-variables.html) — the full flag list.
- [Configuration Reference](configuration-reference.html) — `logs-server-log-file`, `mcp-log-file`, `mask-sensitive-data`.
- [Secure Webhooks and Pull Requests](secure-webhooks-and-pull-requests.html) — deployment and hardening guidance.


---

Source: supported-github-events.md

# Supported GitHub Events

The server routes eight GitHub webhook event types in `webhook_server/libs/github_api.py` (`GitHubWebHook.process()`). Which of them actually arrive depends on the `repositories.<short-repo-name>.events` subscription configured in `config.yaml`.

| Event | What it does |
| --- | --- |
| `ping` | Logged and acknowledged. Token usage is recorded; no further processing. |
| `push` | Only tag pushes are processed: the repository is cloned at the tag ref and the push handler runs (PyPI upload, container build). Branch pushes and branch/tag deletions are logged and skipped. |
| `pull_request` | Clones the repository, initializes the OWNERS-file handler, and hands off to the pull request handler (labels, merge eligibility, welcome message on `opened`/`ready_for_review`, CI/CD runs). |
| `issue_comment` | Clones the repository, initializes the OWNERS-file handler, and hands off to the issue-comment handler (slash commands, reviewer selection). |
| `pull_request_review` | Clones the repository, initializes the OWNERS-file handler, and hands off to the pull request review handler. |
| `check_run` | Processed only for `action: completed`, skipping the `can-be-merged` check itself when its conclusion is not `success` and skipping check runs whose `head_sha` no longer matches the PR head. Otherwise clones the repository and hands off to the check run handler, then debounces a merge-eligibility recheck. |
| `status` | Processed only for terminal commit states that match the current PR head. Re-evaluates can-be-merged (debounced). |
| `pull_request_review_thread` | Processed only for the `resolved`/`unresolved` actions, and only when `required_conversation_resolution` is enabled. Re-evaluates can-be-merged (debounced). |

Any other event type, or an event for which no pull request can be resolved, is logged and skipped without further processing.

## Restricting events per repository

`config.yaml` accepts an `events` array on each repository entry:

```yaml
    repositories:
      my-repo:
        name: my-org/my-repo
        events:
          - pull_request
          - issue_comment
          - check_run
```

The mapping key is the repository's **short** name (`my-repo`) — that is what webhook-time configuration looks up — while `name` carries the full `owner/repo` GitHub name.

The schema types it as an array of strings with no enum, so the value is passed to GitHub as-is when the hook is created or updated.

Default when the key is omitted: `["*"]` — `webhook_server/utils/webhook.py` reads `data.get("events", ["*"])`, which subscribes to every event GitHub supports.

The subscription is reconciled at startup by `create_webhook()`:

- If no hook with a matching URL exists, the hook is created with the configured events.
- If a matching hook exists and its event list differs from the configured list, the hook is edited in place (`Updating webhook events: <old> -> <new>` in the log).
- If it already matches, nothing changes (`Hook already exists`).
- If the webhook URL matches but secret presence differs, the old hook is deleted and a new one created.

To verify the live subscription:

1. Check the startup log. It reports creation, update (with both event lists), or an already-correct hook for every managed repository.
2. Inspect the hook on GitHub itself — repository **Settings → Webhooks → Add Webhook → Configure**, or the hooks API endpoint for the repository, where the `events` array must match the config exactly.

Because the default is `["*"]`, a repository that only needs pull request processing will still receive every other event type; the server then skips each one at routing time.

## Events skipped early

Two filters run at the very top of `process()`, before `get_api_users()`, so skipped deliveries cost no `get_user()` API calls:

- `pull_request_review_thread` — skipped unless the payload `action` is `resolved` or `unresolved` **and** `required_conversation_resolution` is enabled. The log line reports either `action=<action>, skipped` or `required_conversation_resolution disabled`.
- `status` — skipped when the payload `state` is `pending`; only terminal states continue. Log: `status (state=pending, skipped)`.

Further per-event skips happen later, after the PR is resolved: stale `synchronize` / `check_run` / `status` payloads whose SHA is no longer the PR head, non-`completed` `check_run` actions, non-success `can-be-merged` conclusions, and `push` deletions and branch pushes.


---
