From 04fb63205e4e14d2b8dbd87e061c8158629641aa Mon Sep 17 00:00:00 2001 From: Adrian Martin Garcia Date: Sat, 23 May 2026 23:07:27 +0200 Subject: [PATCH] fix: Fixed workflow update --- .github/dependabot.yml | 6 +- README.md | 98 +++- context7.json | 20 +- index.html | 912 +++++++++++++++++++++++++++++++++++++ llms.txt | 473 +++++++++++++++++++ src/pr_generator/config.py | 43 +- tests/test_config.py | 219 ++++++++- 7 files changed, 1760 insertions(+), 11 deletions(-) create mode 100644 index.html create mode 100644 llms.txt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e9d6f3a..9a88600 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ updates: - package-ecosystem: github-actions directory: "/" schedule: - interval: monthly + interval: weekly open-pull-requests-limit: 10 labels: - enhancement @@ -23,7 +23,7 @@ updates: - package-ecosystem: pip directory: "/" schedule: - interval: monthly + interval: weekly open-pull-requests-limit: 10 labels: - enhancement @@ -43,7 +43,7 @@ updates: - package-ecosystem: docker directory: "/" schedule: - interval: monthly + interval: weekly open-pull-requests-limit: 10 labels: - enhancement diff --git a/README.md b/README.md index 88ac7dc..c08db80 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,17 @@ Automated Pull Request creation daemon for **GitHub** and **Bitbucket Cloud**. - [Quick start](#quick-start) - [Configuration](#configuration) - [YAML file](#yaml-file) + - [Environment variables](#environment-variables) - [Providers](#providers) - [GitHub — App authentication](#github--app-authentication) - [GitHub — PAT authentication](#github--pat-authentication) - [Bitbucket Cloud](#bitbucket-cloud) - [Rules](#rules) +- [ArgoCD Image Updater integration](#argocd-image-updater-integration) - [Health endpoints](#health-endpoints) - [Docker](#docker) - [Development](#development) +- [Troubleshooting](#troubleshooting) --- @@ -186,7 +189,7 @@ rules: | `providers..installation_id` | string | *(auto)* | Installation ID; resolved automatically if omitted *(GitHub App auth)* | | `providers..private_key_path` | string | — | Path to GitHub App private key PEM file *(GitHub App auth)* | | `providers..auth_method` | string | `"app"` | `app` (GitHub App) or `pat` (Personal Access Token) *(GitHub only)* | -| `providers..token_env` | string | `"GITHUB_TOKEN"` / `"BITBUCKET_TOKEN"` | Env var name containing the token *(PAT / Bitbucket)* | +| `providers..token_env` | string | `"GITHUB_TOKEN"` / `"BITBUCKET_TOKEN"` | Env var name containing the token *(PAT / Bitbucket)*. Must be **unique** across all enabled providers of the same type — duplicate values raise a `ValueError` at startup | | `providers..workspace` | string | — | Bitbucket workspace slug *(Bitbucket only)* | | `providers..repo_slug` | string | — | Bitbucket repository slug *(Bitbucket only)* | | `providers..close_source_branch` | bool | `true` | Delete source branch after PR merges *(Bitbucket only)* | @@ -196,6 +199,18 @@ rules: --- +## Environment variables + +| Variable | Description | +|----------|-------------| +| `CONFIG_PATH` | Path to the YAML config file. Default: `/etc/pr-generator/config.yaml` | +| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM key (plain text or base64-encoded). Used **only** when `private_key_path` is absent or empty in config — if `private_key_path` is set but the file does not exist, the application raises `FileNotFoundError` without falling back to this variable | +| `GITHUB_TOKEN` | Default token env var for GitHub PAT providers (`token_env: GITHUB_TOKEN`) | +| `BITBUCKET_TOKEN` | Default token env var for Bitbucket providers (`token_env: BITBUCKET_TOKEN`) | +| *any name* | Custom env var referenced by `token_env` in provider config | + +--- + ## Providers ### GitHub App @@ -241,6 +256,40 @@ Multiple rules are supported. --- +## ArgoCD Image Updater integration + +`pr-generator` pairs naturally with [Argo CD Image Updater](https://argocd-image-updater.readthedocs.io/). +Image Updater creates branches named `argocd-image-updater-set---`. +Configure rules to catch those branches and open PRs toward the appropriate target branch per environment. + +```yaml +scan_frequency: 120 + +providers: + github: + enabled: true + owner: my-org + repo: gitops-repo + auth_method: app + app_id: "123456" + private_key_path: /secrets/github-app.pem + +rules: + - pattern: "argocd-image-updater-.*-dev-.*" + destinations: + github: develop + + - pattern: "argocd-image-updater-.*-staging-.*" + destinations: + github: staging + + - pattern: "argocd-image-updater-.*-pro-.*" + destinations: + github: main +``` + +--- + ## Health endpoints A lightweight HTTP server starts on `health_port` (default `8080`): @@ -331,3 +380,50 @@ tests/ ├── test_models.py # Model tests └── test_scanner.py # Scan cycle tests ``` + +--- + +## Troubleshooting + +### Application exits with `FileNotFoundError` + +``` +FileNotFoundError: [Core] private_key_path '/secrets/github-app.pem' does not exist. +``` + +`private_key_path` is set in `config.yaml` but the file is not present at that path. +Either mount the PEM file at the configured path, or remove `private_key_path` from +the config and set the `GITHUB_APP_PRIVATE_KEY` environment variable instead. + +### `ValueError: duplicate tokenEnv` + +``` +ValueError: [Core] Providers 'bb-eu' and 'bb-us' both use tokenEnv 'BITBUCKET_TOKEN'. +``` + +Two enabled providers of the same type share the same `token_env` value. Assign a +unique env var name to each provider and export the corresponding variable in your +runtime environment. + +### `/readyz` returns `503` + +This is expected during startup. The endpoint returns `503 not ready` until the first +full scan cycle completes. If it never flips to `200`, check the application logs for +errors in the scan cycle (API auth failures, missing config fields, network issues). + +### No PRs are created (dry_run is false, branches exist) + +1. **Regex anchoring** — rules use `re.match`, which is anchored at the start of the + string. A pattern `feature/.*` will **not** match `hotfix/feature/x`. Enable + `log_level: DEBUG` to see per-branch matching decisions. +2. **Provider name mismatch** — the name in `rules[].destinations` must exactly match + the provider key under `providers:`. +3. **Destination branch excluded** — pr-generator skips branches whose name equals the + destination branch to avoid self-targeting PRs. + +### GitHub App: `RuntimeError: Could not resolve installation id` + +Set `installation_id` explicitly in the provider config (find it in your GitHub App +settings under _Installations_), or ensure the GitHub App is installed on the target +repository. + diff --git a/context7.json b/context7.json index 4d8c5d9..7ac711a 100644 --- a/context7.json +++ b/context7.json @@ -1,4 +1,20 @@ { + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "pr-generator", + "description": "Automated Pull Request creation daemon for GitHub and Bitbucket Cloud. Periodically scans repository branches, matches them against configurable regex patterns, and automatically opens Pull Requests toward the configured destination branches.", "url": "https://context7.com/devops-ia/pr-generator", - "public_key": "pk_poNqP0E20Jn62M589pdY6" -} \ No newline at end of file + "public_key": "pk_poNqP0E20Jn62M589pdY6", + "folders": ["src"], + "excludeFolders": ["tests", ".github"], + "excludeFiles": ["CHANGELOG.md"], + "rules": [ + "Use CONFIG_PATH environment variable to override the default config path (/etc/pr-generator/config.yaml)", + "Provider names 'github' and 'bitbucket' auto-infer their type; all other names require an explicit 'type: github' or 'type: bitbucket' field", + "Each provider using token-based auth (GitHub PAT or Bitbucket) must have a unique token_env value — duplicate token_env across providers of the same type raises ValueError at startup", + "private_key_path must point to an existing file — if the path is set but the file is missing, FileNotFoundError is raised (no silent fallthrough to GITHUB_APP_PRIVATE_KEY env var)", + "The application always runs as a single replica — no distributed coordination", + "Dry-run mode (dry_run: true) logs PR creation intent without making any API calls", + "GET /readyz returns 503 until the first scan cycle completes — use GET /livez for liveness probes", + "Rules use re.match (anchored at start of string) — patterns like 'feature/.*' match branches starting with 'feature/'" + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..89ae5eb --- /dev/null +++ b/index.html @@ -0,0 +1,912 @@ + + + + + + pr-generator · Automated PR daemon for GitHub & Bitbucket + + + + + + + + + + + + +
+
+ +
+
+ + +
+
+
+ + GitOps · Python · Automation +
+

+ Automated PRs for
GitHub & Bitbucket +

+

+ pr-generator watches for branches matching configurable regex patterns + and automatically opens Pull Requests. Built for + ArgoCD Image Updater and GitOps workflows. +

+ +
+ Version + Python 3.11+ + Apache 2.0 License + + Docker Build + +
+ +
+
+ Quick start — Docker + +
+
$ docker run \
+    -e GITHUB_APP_ID=123456 \
+    -e GITHUB_INSTALLATION_ID=78901234 \
+    -e GITHUB_APP_PRIVATE_KEY="$(cat github-app.pem)" \
+    -v ./config.yaml:/etc/pr-generator/config.yaml \
+    ghcr.io/devops-ia/pr-generator:latest
+
+
+
+ + +
+
+ +

Everything you need, out of the box

+

A lightweight Python daemon with no persistent storage requirements. Runs anywhere: bare metal, Docker, or Kubernetes.

+
+ +
+ +

GitHub App & PAT

+

GitHub App (PEM private key) or Personal Access Token. Configure per provider with auth_method.

+
+ +
+ +

Bitbucket Cloud

+

Full Bitbucket Cloud support via access token. Run GitHub and Bitbucket providers in a single process.

+
+ +
+ +

Multi-provider

+

Multiple named providers of the same type. Different orgs, workspaces, or repos — all watched in one daemon.

+
+ +
+ +

Regex branch patterns

+

Match any branch naming convention with full Python regex. Each rule can target multiple destination branches.

+
+ +
+ +

Safety by default

+

Duplicate PR detection prevents opening a second PR for the same branch. dry_run mode for safe testing in production.

+
+ +
+ +

Health endpoint

+

Built-in HTTP server exposes GET /health for liveness probes. No extra dependencies required.

+
+ +
+ +

Configurable scan frequency

+

Set scan_frequency to control how often branches are polled. Defaults to 60 seconds.

+
+ +
+ +

Kubernetes-native

+

Official Helm chart with ServiceAccount, PDB, startup probe, and security-hardened pod spec.

+
+ +
+
+
+ + +
+
+ +

Multiple deployment options

+

Run via Docker (recommended), install from PyPI, or deploy on Kubernetes with the Helm chart.

+ +
+
+ docker-compose.yml + +
+
services:
+  pr-generator:
+    image: ghcr.io/devops-ia/pr-generator:latest
+    restart: unless-stopped
+    volumes:
+      - ./config.yaml:/etc/pr-generator/config.yaml:ro
+    environment:
+      GITHUB_APP_ID: "123456"
+      GITHUB_INSTALLATION_ID: "78901234"
+    secrets:
+      - github_pem
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
+      interval: 30s
+      retries: 3
+
+secrets:
+  github_pem:
+    file: ./github-app.pem
+
+ +
+
+ bash — PyPI + +
+
$ pip install pr-generator
+$ pr-generator --config /etc/pr-generator/config.yaml
+
+ +
+
+ bash — Kubernetes (Helm) + +
+
$ helm repo add helm-pr-generator https://devops-ia.github.io/helm-pr-generator
+$ helm repo update
+$ helm install pr-generator helm-pr-generator/pr-generator \
+    --namespace pr-generator --create-namespace \
+    --set config.providers.github.owner=my-org \
+    --set config.providers.github.repo=my-repo \
+    --set config.providers.github.appId="123456" \
+    --set secrets.github.privateKey="$(cat github-app.pem)"
+
+
+
+ + +
+
+ +

config.yaml reference

+

+ Mount your config at /etc/pr-generator/config.yaml. + Credentials are passed via environment variables — never in the config file. +

+ +
+
+ config.yaml — GitHub App + Bitbucket + +
+
scan_frequency: 60          # seconds between scan cycles
+dry_run: false                # set true to simulate without creating PRs
+
+providers:
+  github:                      # name is free-form; type inferred from key
+    enabled: true
+    owner: my-org
+    repo: my-app
+    auth_method: app           # "app" (PEM) or "pat" (token)
+    app_id: "123456"           # from env: GITHUB_APP_ID
+    installation_id: "78901234"
+    private_key_path: /secrets/github.pem  # or use GITHUB_APP_PRIVATE_KEY env
+
+  bitbucket:
+    enabled: true
+    workspace: my-workspace
+    repo_slug: my-app
+    token_env: BITBUCKET_TOKEN   # env var holding the access token
+
+rules:
+  - name: image-updater-prs
+    branch_pattern: "^image-updater/.*"
+    destinations:
+      - provider: github
+        base: main
+      - provider: bitbucket
+        base: main
+
+ +
+ Security: Each provider of the same type must use a unique token_env value. + The daemon raises a ValueError at startup if duplicates are detected — preventing silent credential overwrite. +
+
+
+ + +
+
+ +

GitOps integration

+

+ ArgoCD Image Updater pushes image version bumps to a new branch. pr-generator watches + that branch pattern and opens the PR automatically — closing the GitOps loop without manual intervention. +

+ +
+
+ argocd Application annotations + +
+
apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+  annotations:
+    # Image Updater pushes to a branch, pr-generator opens the PR
+    argocd-image-updater.argoproj.io/write-back-method: git:repobranch
+    argocd-image-updater.argoproj.io/git-branch: main:image-updater/{{.SHA}}
+
+ +
+
+ config.yaml — matching rule + +
+
rules:
+  - name: image-updater-prs
+    branch_pattern: "^image-updater/.*"
+    destinations:
+      - provider: github
+        base: main
+
+
+
+ + +
+
+ +

Key configuration fields

+

+ Common fields and environment variables. Full reference in + README.md + and llms.txt. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Field / Env varDescriptionType
scan_frequencySeconds between branch scan cycles.int
dry_runSimulate PR creation without modifying repositories.bool
providers.*.auth_methodapp (GitHub App PEM) or pat (Personal Access Token).str
providers.*.private_key_pathPath to PEM file. Raises FileNotFoundError if set but missing.str
providers.*.token_envEnv var holding the access token. Must be unique per provider type.str
rules[].branch_patternPython regex matched against each branch name.regex
rules[].destinations[].baseBase branch the PR targets.str
GITHUB_APP_IDGitHub App numeric ID (alternative to inline app_id).env
GITHUB_INSTALLATION_IDGitHub App installation ID.env
GITHUB_APP_PRIVATE_KEYGitHub App PEM key as a string (alternative to private_key_path).env
+
+
+
+ + +
+
+ +

Common issues

+

Most issues come down to auth, branch regex, or credential uniqueness.

+ +
+ No PRs created — branches exist but daemon is silent +
+

Check the daemon output first. Look for lines prefixed with [SCAN] to see which branches were evaluated and why they were skipped.

+

Common causes:

+
    +
  • Branch pattern does not match. Test your regex: import re; re.match(r'^image-updater/.*', 'image-updater/my-branch')
  • +
  • dry_run: true — PRs are simulated only.
  • +
  • Provider is disabled (enabled: false).
  • +
  • A PR already exists for that branch — duplicates are skipped.
  • +
+
+
+ +
+ GitHub App: FileNotFoundError on private key +
+

If private_key_path is set, the file must exist. The daemon raises FileNotFoundError at startup rather than silently falling back to the env var.

+
FileNotFoundError: Private key file not found: /secrets/github.pem
+

Either correct the path or remove private_key_path and set GITHUB_APP_PRIVATE_KEY as an environment variable instead.

+
+
+ +
+ ValueError: duplicate token_env across providers +
+

Each provider of the same type must map to a distinct environment variable. The daemon refuses to start if two providers share the same token_env:

+
ValueError: Duplicate token_env 'BITBUCKET_TOKEN' across bitbucket providers: bitbucket, bitbucket-org2
+

Fix by assigning unique env var names:

+
providers:
+  bitbucket:
+    token_env: BITBUCKET_TOKEN
+  bitbucket-org2:
+    type: bitbucket
+    token_env: BITBUCKET_ORG2_TOKEN   # must differ
+
+
+ +
+ GitHub installation_id vs app_id confusion +
+

app_id is shown on the GitHub App settings page (Settings → Developer settings → GitHub Apps → your app). The installation_id is found at https://github.com/organizations/<org>/settings/installations — it appears in the URL when you click on the installed app.

+

Both must be strings (quoted) in config.yaml.

+
+
+
+
+ + + + + + + + diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..4379f3a --- /dev/null +++ b/llms.txt @@ -0,0 +1,473 @@ +# pr-generator + +> Automated Pull Request creation daemon for GitHub and Bitbucket Cloud. +> Periodically scans repository branches against configurable regex patterns and +> opens Pull Requests toward the configured destination branches — skipping any +> PR that already exists. + +## Docs + +- [README](https://github.com/devops-ia/pr-generator/blob/main/README.md): Full documentation — configuration, providers, rules, health endpoints, Docker, development + +## Install and Run + +```bash +# Install from source +pip install -e . + +# Run (default config path: /etc/pr-generator/config.yaml) +pr-generator + +# Override config path +CONFIG_PATH=./config.yaml pr-generator + +# Check version +pr-generator --version +``` + +## Docker + +```bash +# GitHub App + Bitbucket +docker run --rm \ + -v "$(pwd)/config.yaml:/etc/pr-generator/config.yaml:ro" \ + -v "$(pwd)/github-app.pem:/secrets/github-app.pem:ro" \ + -e BITBUCKET_TOKEN=your-token \ + -p 8080:8080 \ + ghcr.io/devops-ia/pr-generator:latest + +# GitHub PAT only +docker run --rm \ + -v "$(pwd)/config.yaml:/etc/pr-generator/config.yaml:ro" \ + -e GITHUB_TOKEN=ghp_xxxx \ + -p 8080:8080 \ + ghcr.io/devops-ia/pr-generator:latest +``` + +## Configuration File (config.yaml) + +```yaml +# /etc/pr-generator/config.yaml (override with CONFIG_PATH env var) + +scan_frequency: 300 # seconds between scan cycles (default: 300, minimum: 10) +log_level: INFO # DEBUG | INFO | WARNING | ERROR +log_format: text # "text" (human-readable) | "json" (structured, for log aggregators) +dry_run: false # when true, PRs are logged but never created +health_port: 8080 # port for the built-in health HTTP server + +providers: + github: + enabled: true + owner: my-org + repo: my-repo + auth_method: app # "app" (GitHub App, default) | "pat" (Personal Access Token) + app_id: "123456" + installation_id: "78901234" # optional — auto-resolved from repo if omitted + private_key_path: /secrets/github-app.pem + # Alternative: GITHUB_APP_PRIVATE_KEY env var (plain PEM or base64-encoded) + timeout: 30 + + bitbucket: + enabled: true + workspace: my-workspace + repo_slug: my-repo + token_env: BITBUCKET_TOKEN # env var holding the bearer token + close_source_branch: true # delete source branch after PR merges (default: true) + timeout: 30 + +rules: + - pattern: "feature/.*" # Python regex, re.match (anchored at start) + destinations: + github: main # create PRs toward "main" on GitHub + bitbucket: develop # create PRs toward "develop" on Bitbucket + + - pattern: "release/.*" + destinations: + github: main + + - pattern: ".*-hotfix-.*" + destinations: + bitbucket: master +``` + +## GitHub PAT Authentication + +```yaml +providers: + github: + enabled: true + auth_method: pat + owner: my-org + repo: my-repo + token_env: GITHUB_TOKEN # env var name; defaults to "GITHUB_TOKEN" + timeout: 30 +``` + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +CONFIG_PATH=./config.yaml pr-generator +``` + +## Multi-Provider Setup (multiple orgs / workspaces) + +```yaml +# All providers of the same type share one secret per type. +# Each provider MUST use a unique token_env to avoid credential collisions. + +providers: + github-acme: + type: github # required for non-standard key names + enabled: true + auth_method: app + owner: acme-org + repo: backend + app_id: "111" + private_key_path: /secrets/acme-app.pem + + github-skunkworks: + type: github + enabled: true + auth_method: pat + owner: skunkworks-org + repo: platform + token_env: SKUNKWORKS_GITHUB_TOKEN # must be unique across PAT providers + + bitbucket-eu: + type: bitbucket + enabled: true + workspace: eu-workspace + repo_slug: my-repo + token_env: BITBUCKET_EU_TOKEN # must be unique across Bitbucket providers + + bitbucket-us: + type: bitbucket + enabled: true + workspace: us-workspace + repo_slug: my-repo + token_env: BITBUCKET_US_TOKEN # different env var — required + +rules: + - pattern: "feature/.*" + destinations: + github-acme: main + github-skunkworks: develop + bitbucket-eu: develop + bitbucket-us: develop +``` + +## ArgoCD Image Updater Integration + +pr-generator is designed to work with [Argo CD Image Updater](https://argocd-image-updater.readthedocs.io/). +Image Updater creates branches like `argocd-image-updater-set---`. +Configure rules to automatically open PRs from those branches to the target branch. + +```yaml +scan_frequency: 120 + +providers: + github: + enabled: true + owner: my-org + repo: gitops-repo + auth_method: app + app_id: "123456" + private_key_path: /secrets/github-app.pem + +rules: + # dev environment — PR toward develop + - pattern: "argocd-image-updater-.*-dev-.*" + destinations: + github: develop + + # staging environment — PR toward staging + - pattern: "argocd-image-updater-.*-staging-.*" + destinations: + github: staging + + # production environment — PR toward main + - pattern: "argocd-image-updater-.*-pro-.*" + destinations: + github: main +``` + +## Environment Variables Reference + +| Variable | Description | Default | +|----------|-------------|---------| +| `CONFIG_PATH` | Path to the YAML config file | `/etc/pr-generator/config.yaml` | +| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM (plain or base64). Used only when `private_key_path` is absent or empty | — | +| `GITHUB_TOKEN` | Default env var for GitHub PAT providers (`token_env: GITHUB_TOKEN`) | — | +| `BITBUCKET_TOKEN` | Default env var for Bitbucket providers (`token_env: BITBUCKET_TOKEN`) | — | +| *any custom name* | Custom token env var referenced by `token_env` in provider config | — | + +**Note on `private_key_path`**: if the field is set but the file does not exist, the application +raises `FileNotFoundError` at startup — there is no silent fallthrough to `GITHUB_APP_PRIVATE_KEY`. + +## Security — tokenEnv Uniqueness + +Multiple providers of the same type that share a `token_env` value will raise a `ValueError` at +startup. This prevents silent credential collisions where two providers receive the same token. + +```yaml +# WRONG — both providers see the same BITBUCKET_TOKEN → ValueError at startup +providers: + bb-eu: + type: bitbucket + token_env: BITBUCKET_TOKEN # collision! + bb-us: + type: bitbucket + token_env: BITBUCKET_TOKEN # collision! + +# CORRECT — each provider has its own env var +providers: + bb-eu: + type: bitbucket + token_env: BITBUCKET_EU_TOKEN + bb-us: + type: bitbucket + token_env: BITBUCKET_US_TOKEN +``` + +## Health Endpoints + +```bash +# Liveness — 200 while running, 503 during shutdown +curl http://localhost:8080/livez + +# Readiness — 200 after first scan cycle, 503 before +curl http://localhost:8080/readyz + +# Healthz — alias for /livez +curl http://localhost:8080/healthz +``` + +Kubernetes probe configuration: + +```yaml +livenessProbe: + httpGet: + path: /livez + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 +``` + +## Programmatic API — Load Config + +```python +import os +from pr_generator.config import load_config + +os.environ["CONFIG_PATH"] = "./config.yaml" +config = load_config() + +print(config.scan_frequency) # int +print(config.dry_run) # bool +print(list(config.providers)) # ["github", "bitbucket"] +print(config.rules[0].pattern) # "feature/.*" +``` + +## Programmatic API — GitHubProvider + +```python +from pr_generator.models import ProviderConfig +from pr_generator.providers.github import GitHubProvider, GitHubError + +# GitHub App +config = ProviderConfig( + name="github", + enabled=True, + type="github", + owner="my-org", + repo="my-repo", + auth_method="app", + app_id="123456", + installation_id="78901234", # optional — auto-resolved if empty + private_key="-----BEGIN RSA PRIVATE KEY-----\n...", + timeout=30.0, +) + +provider = GitHubProvider(config) + +try: + branches = provider.get_branches() # ["main", "develop", "feature/x"] + exists = provider.check_existing_pr("feature/x", "main") # True / False + if not exists: + provider.create_pull_request("feature/x", "main") + provider.reset_cycle_cache() # call at start of each cycle +except GitHubError as exc: + print(f"error {exc.status_code}: {exc}") +``` + +```python +# GitHub PAT +config_pat = ProviderConfig( + name="github", + enabled=True, + type="github", + owner="my-org", + repo="my-repo", + auth_method="pat", + token="ghp_xxxxxxxxxxxx", + timeout=30.0, +) +provider = GitHubProvider(config_pat) +``` + +## Programmatic API — BitbucketProvider + +```python +from pr_generator.models import ProviderConfig +from pr_generator.providers.bitbucket import BitbucketProvider, BitbucketError + +config = ProviderConfig( + name="bitbucket", + enabled=True, + type="bitbucket", + workspace="my-workspace", + repo_slug="my-repo", + token="your-bearer-token", + timeout=30.0, + close_source_branch=True, +) + +provider = BitbucketProvider(config) + +try: + branches = provider.get_branches() + exists = provider.check_existing_pr("feature/x", "develop") + if not exists: + provider.create_pull_request("feature/x", "develop") + # PR is created with default reviewers fetched automatically + provider.reset_cycle_cache() +except BitbucketError as exc: + print(f"error {exc.status_code}: {exc}") +``` + +## Programmatic API — Scan Cycle + +```python +from pr_generator.config import load_config +from pr_generator.scanner import scan_cycle +from pr_generator.providers.github import GitHubProvider +from pr_generator.providers.bitbucket import BitbucketProvider + +config = load_config() + +providers = {} +for name, pconf in config.providers.items(): + if pconf.type == "github": + providers[name] = GitHubProvider(pconf) + elif pconf.type == "bitbucket": + providers[name] = BitbucketProvider(pconf) + +result = scan_cycle(config, providers, cycle_id=1) + +for r in result.rule_results: + print(f"pattern={r.rule_pattern} provider={r.provider} dest={r.destination}") + print(f" processed={r.processed} created={r.created} " + f"skipped={r.skipped_existing} dry_run={r.simulated} errors={r.errors}") +``` + +## Programmatic API — Health Server + +```python +from threading import Event +from pr_generator.health import start_health_server + +stop_event = Event() +server, ready_event = start_health_server(port=8080, stop_event=stop_event) + +# After first successful scan cycle: +ready_event.set() # /readyz starts returning 200 + +# Graceful shutdown: +stop_event.set() # /livez starts returning 503 +``` + +## Data Models + +```python +from pr_generator.models import AppConfig, ProviderConfig, ScanRule, RuleResult, CycleResult +import re + +# ProviderConfig — immutable, frozen dataclass +provider = ProviderConfig( + name="github", + enabled=True, + type="github", # "github" | "bitbucket" + owner="my-org", + repo="my-repo", + auth_method="app", # "app" | "pat" + app_id="123456", + installation_id="78901234", + private_key="-----BEGIN RSA PRIVATE KEY-----\n...", + timeout=30.0, +) + +# ScanRule +rule = ScanRule( + pattern="feature/.*", + compiled=re.compile("feature/.*"), + destinations={"github": "main", "bitbucket": "develop"}, +) + +# RuleResult — outcome of one rule×provider pair per cycle +result = RuleResult( + rule_pattern="feature/.*", + provider="github", + destination="main", + processed=5, + created=2, + skipped_existing=3, + simulated=0, # dry-run count + errors=0, +) +``` + +## Troubleshooting + +### Application exits immediately with FileNotFoundError + +``` +FileNotFoundError: [Core] private_key_path '/secrets/github-app.pem' does not exist. +``` + +The `private_key_path` is set in config.yaml but the file is not mounted. +Mount the PEM file at the configured path or remove `private_key_path` and set +`GITHUB_APP_PRIVATE_KEY` instead. + +### ValueError: duplicate tokenEnv + +``` +ValueError: [Core] Providers 'bb-eu' and 'bb-us' both use tokenEnv 'BITBUCKET_TOKEN'. +``` + +Two enabled providers of the same type share the same `token_env`. Assign a unique +env var name to each provider and export the corresponding variable. + +### /readyz returns 503 + +Normal during startup — the endpoint returns 503 until the first full scan cycle +completes. Check `/livez` for liveness. If readiness never flips to 200, check the +logs for errors in the scan cycle (API auth failures, missing config fields, etc.). + +### No PRs created (dry_run: false, branches exist) + +- Confirm the branch regex uses `re.match` semantics (anchored at start). A pattern + `feature/.*` will NOT match `hotfix/feature/x` — prefix anchoring is intentional. +- Confirm the provider name in `rules[].destinations` matches the provider key in + `providers:` exactly. +- Enable `log_level: DEBUG` to see per-branch matching decisions. + +### GitHub App: "Could not resolve installation id" + +Set `installation_id` explicitly in the provider config, or grant the GitHub App +installation access to the target repository. diff --git a/src/pr_generator/config.py b/src/pr_generator/config.py index 587ec42..8c274ab 100644 --- a/src/pr_generator/config.py +++ b/src/pr_generator/config.py @@ -112,9 +112,37 @@ def _parse_providers_from_yaml(raw: dict) -> dict[str, ProviderConfig]: else: providers[pname] = _parse_bitbucket_provider(pname, pcfg) + _validate_token_env_uniqueness(raw) return providers +def _validate_token_env_uniqueness(raw: dict) -> None: + """Raise if two providers of the same type share a tokenEnv value. + + A duplicated tokenEnv means only one provider's credential ends up in the + container environment — the other silently gets the wrong token. + """ + seen: dict[str, str] = {} # tokenEnv value -> first provider name that uses it + for pname, pcfg in raw.items(): + if not isinstance(pcfg, dict) or not pcfg.get("enabled", False): + continue + ptype = str(pcfg.get("type", "")).lower() or ( + pname if pname in {"github", "bitbucket"} else "" + ) + if ptype == "github" and str(pcfg.get("auth_method", "app")).lower() == "pat": + env = str(pcfg.get("token_env", "GITHUB_TOKEN")) + elif ptype == "bitbucket": + env = str(pcfg.get("token_env", "BITBUCKET_TOKEN")) + else: + continue + if env in seen: + raise ValueError( + f"[Core] Providers '{seen[env]}' and '{pname}' both use tokenEnv '{env}'. " + "Each provider must use a unique env var name to avoid credential collisions." + ) + seen[env] = pname + + def _parse_github_provider(name: str, gh: dict) -> ProviderConfig: """Build a ProviderConfig for a GitHub provider entry.""" auth_method = str(gh.get("auth_method", "app")).lower() @@ -198,9 +226,20 @@ def _parse_bitbucket_provider(name: str, bb: dict) -> ProviderConfig: def _load_private_key(gh_cfg: dict) -> str: - """Load GitHub App private key from file path or env var.""" + """Load GitHub App private key from file path or env var. + + If ``private_key_path`` is set but the file does not exist, a + ``FileNotFoundError`` is raised immediately — no silent fallthrough to the + env-var fallback. That fallback is only tried when ``private_key_path`` is + absent or empty. + """ key_path = str(gh_cfg.get("private_key_path", "")) - if key_path and os.path.exists(key_path): + if key_path: + if not os.path.exists(key_path): + raise FileNotFoundError( + f"[Core] private_key_path '{key_path}' does not exist. " + "Check the mounted secret volume or correct the path in config.yaml." + ) with open(key_path) as fh: return fh.read() diff --git a/tests/test_config.py b/tests/test_config.py index 15b2cf4..30f56a1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,8 +18,9 @@ def _write_config(tmp_path, content: str) -> str: class TestLoadFromFile: def test_single_rule_both_providers(self, tmp_path, monkeypatch): monkeypatch.setenv("BITBUCKET_TOKEN", "bb-token") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", _FAKE_PEM) - path = _write_config(tmp_path, """ + key_file = tmp_path / "app.pem" + key_file.write_text(_FAKE_PEM) + path = _write_config(tmp_path, f""" scan_frequency: 60 log_level: DEBUG dry_run: true @@ -31,7 +32,7 @@ def test_single_rule_both_providers(self, tmp_path, monkeypatch): repo: my-repo app_id: "111" installation_id: "222" - private_key_path: /nonexistent + private_key_path: {key_file} timeout: 10 bitbucket: enabled: true @@ -714,3 +715,215 @@ def test_config_file_not_found_raises(self, monkeypatch): with pytest.raises(FileNotFoundError): load_config() + +class TestPrivateKeyPathSecurity: + """private_key_path must not silently fall through to env-var when the file is absent.""" + + def test_private_key_path_file_not_found_raises(self, tmp_path, monkeypatch): + """A non-existent private_key_path must raise FileNotFoundError, not silently + fall through to the GITHUB_APP_PRIVATE_KEY env var.""" + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", _FAKE_PEM) + path = _write_config(tmp_path, """ + providers: + github: + enabled: true + owner: org + repo: repo + app_id: "1" + private_key_path: /this/path/does/not/exist.pem + rules: + - pattern: ".*" + destinations: + github: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + with pytest.raises(FileNotFoundError, match="private_key_path"): + load_config() + + def test_private_key_path_empty_falls_through_to_env(self, tmp_path, monkeypatch): + """When private_key_path is absent/empty the env-var fallback still works.""" + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", _FAKE_PEM) + path = _write_config(tmp_path, """ + providers: + github: + enabled: true + owner: org + repo: repo + app_id: "1" + rules: + - pattern: ".*" + destinations: + github: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + cfg = load_config() + assert cfg.providers["github"].private_key == _FAKE_PEM + + def test_private_key_path_set_and_file_exists_loads_key(self, tmp_path, monkeypatch): + """When private_key_path points to an existing file the key is loaded from it.""" + key_file = tmp_path / "app.pem" + key_file.write_text(_FAKE_PEM) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY", raising=False) + path = _write_config(tmp_path, f""" + providers: + github: + enabled: true + owner: org + repo: repo + app_id: "1" + private_key_path: {key_file} + rules: + - pattern: ".*" + destinations: + github: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + cfg = load_config() + assert cfg.providers["github"].private_key == _FAKE_PEM + + +class TestTokenEnvUniqueness: + """tokenEnv collisions across providers of the same type must be rejected.""" + + def test_two_bitbucket_providers_same_token_env_raises(self, tmp_path, monkeypatch): + """Two enabled Bitbucket providers sharing tokenEnv must raise ValueError.""" + monkeypatch.setenv("BITBUCKET_TOKEN", "tok") + path = _write_config(tmp_path, """ + providers: + bitbucket-a: + type: bitbucket + enabled: true + workspace: ws-a + repo_slug: rs-a + token_env: BITBUCKET_TOKEN + bitbucket-b: + type: bitbucket + enabled: true + workspace: ws-b + repo_slug: rs-b + token_env: BITBUCKET_TOKEN + rules: + - pattern: ".*" + destinations: + bitbucket-a: main + bitbucket-b: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + with pytest.raises(ValueError, match="tokenEnv"): + load_config() + + def test_two_github_pat_providers_same_token_env_raises(self, tmp_path, monkeypatch): + """Two enabled GitHub PAT providers sharing tokenEnv must raise ValueError.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_tok") + path = _write_config(tmp_path, """ + providers: + github-a: + type: github + enabled: true + auth_method: pat + owner: org-a + repo: repo-a + token_env: GITHUB_TOKEN + github-b: + type: github + enabled: true + auth_method: pat + owner: org-b + repo: repo-b + token_env: GITHUB_TOKEN + rules: + - pattern: ".*" + destinations: + github-a: main + github-b: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + with pytest.raises(ValueError, match="tokenEnv"): + load_config() + + def test_bitbucket_default_token_env_collision_raises(self, tmp_path, monkeypatch): + """Using the default BITBUCKET_TOKEN for two providers must raise ValueError.""" + monkeypatch.setenv("BITBUCKET_TOKEN", "tok") + path = _write_config(tmp_path, """ + providers: + bitbucket: + enabled: true + workspace: ws + repo_slug: rs + bitbucket-org1: + type: bitbucket + enabled: true + workspace: ws2 + repo_slug: rs2 + rules: + - pattern: ".*" + destinations: + bitbucket: main + bitbucket-org1: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + with pytest.raises(ValueError, match="tokenEnv"): + load_config() + + def test_two_bitbucket_providers_distinct_token_envs_ok(self, tmp_path, monkeypatch): + """Two Bitbucket providers with different tokenEnv values must load successfully.""" + monkeypatch.setenv("BB_TOKEN_A", "tok-a") + monkeypatch.setenv("BB_TOKEN_B", "tok-b") + path = _write_config(tmp_path, """ + providers: + bitbucket-a: + type: bitbucket + enabled: true + workspace: ws-a + repo_slug: rs-a + token_env: BB_TOKEN_A + bitbucket-b: + type: bitbucket + enabled: true + workspace: ws-b + repo_slug: rs-b + token_env: BB_TOKEN_B + rules: + - pattern: ".*" + destinations: + bitbucket-a: main + bitbucket-b: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + cfg = load_config() + assert cfg.providers["bitbucket-a"].token == "tok-a" + assert cfg.providers["bitbucket-b"].token == "tok-b" + + def test_disabled_provider_token_env_collision_ignored(self, tmp_path, monkeypatch): + """A disabled provider sharing a tokenEnv must not trigger the uniqueness check.""" + monkeypatch.setenv("BITBUCKET_TOKEN", "tok") + path = _write_config(tmp_path, """ + providers: + bitbucket: + enabled: true + workspace: ws + repo_slug: rs + token_env: BITBUCKET_TOKEN + bitbucket-disabled: + type: bitbucket + enabled: false + workspace: ws2 + repo_slug: rs2 + token_env: BITBUCKET_TOKEN + rules: + - pattern: ".*" + destinations: + bitbucket: main + """) + monkeypatch.setenv("CONFIG_PATH", path) + from pr_generator.config import load_config + cfg = load_config() + assert "bitbucket" in cfg.providers + assert "bitbucket-disabled" not in cfg.providers