Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.git
.github
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
tests/
docs/
.venv/
venv/
*.md
*.egg-info
dist/
build/
99 changes: 99 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Copilot Instructions — pr-generator

## Commands

```bash
# Install (editable, no dev extras needed)
pip install -e .
pip install pytest

# Run full test suite
python -m pytest

# Run a single test file
python -m pytest tests/test_scanner.py -v

# Run a single test by name
python -m pytest tests/test_scanner.py::TestScanCycle::test_dry_run_does_not_create_prs -v

# Run the application locally
CONFIG_PATH=./config.yaml python -m pr_generator

# Run tests with coverage (configured in pyproject.toml)
python -m pytest --cov=pr_generator --cov-report=term-missing
```

There is no linter configured. There is no type-checker configured.

---

## Architecture

`pr-generator` is a long-running polling daemon. The main loop lives in `__main__.py`:

1. Load `AppConfig` from YAML (`CONFIG_PATH`) or legacy env vars (fallback).
2. Instantiate active providers (`GitHubProvider` / `BitbucketProvider`).
3. Start the health HTTP server in a daemon thread.
4. Loop: run `scan_cycle()` → sleep `scan_frequency` seconds → repeat.
5. Graceful shutdown on `SIGTERM`/`SIGINT` via a `threading.Event`.

**Scan cycle** (`scanner.py`) is two-phase, both phases concurrent via `ThreadPoolExecutor`:
- **Phase 1**: fetch all branch names from every active provider in parallel.
- **Phase 2**: for each `rule × provider` pair — filter branches by regex, check for existing PRs, create missing ones.

**Config loading** (`config.py`) priority: YAML file → legacy env vars. YAML supports multiple named providers and multiple rules. Legacy env-var mode supports exactly one rule.

**Provider abstraction** — `ProviderInterface` is a `runtime_checkable` Protocol in `providers/base.py`. Both `GitHubProvider` and `BitbucketProvider` satisfy it structurally (no explicit inheritance). The scanner only uses the interface.

**All HTTP** goes through `request_with_retry` in `http_client.py`. It handles retry/backoff (delays: 0.5 s, 1 s, 2 s) and logging. Providers never call `requests` directly.

**Releases** are automated via `semantic-release` on push to `main`. Version is in `src/pr_generator/__init__.py` and `pyproject.toml`.

---

## Key Conventions

### Logging format
All log lines follow the structured pattern:
```
[Component] Step: step_name action=verb cycle_id=N detail=...
```
Examples: `[GitHub] Step: get_branches action=end total=42`, `[Core] Step: scan_cycle action=start cycle_id=3`.

### `request_with_retry` — `headers` vs `headers_factory`
Pass **`headers`** (a plain dict) when auth tokens don't expire between retries (Bitbucket Bearer token).
Pass **`headers_factory`** (a `() → dict` callable) when tokens may rotate between attempts (GitHub App installation tokens). The factory is called fresh on each retry attempt, so a token refresh is picked up automatically.

### Provider exceptions must carry `status_code`
Both `GitHubError` and `BitbucketError` have the constructor signature:
```python
def __init__(self, message: str, status_code: int | None = None) -> None:
```
`http_client.request_with_retry` calls `exception_cls(message, status_code)`. Any new provider exception class must match this signature.

### Per-cycle caches
Each provider caches PR-existence and branch-existence lookups within one scan cycle. `reset_cycle_cache()` is called at the start of every cycle. Do not persist cache state across cycles.

### Rule matching uses `re.match` (start-anchored)
Patterns are matched with `rule.compiled.match(branch_name)`, not `re.search`. Patterns must match from the beginning of the branch name.

### `AppConfig` and `ProviderConfig` are frozen dataclasses
Neither can be mutated after construction. In tests, build a new instance rather than modifying fields.

### New provider checklist
To add a third provider (e.g. GitLab):
1. Create `src/pr_generator/providers/gitlab.py` implementing all 5 methods of `ProviderInterface`.
2. Define `GitLabError(Exception)` with `(message: str, status_code: int | None = None)`.
3. Add `"gitlab"` to the `ptype` allowlist in `config._parse_providers_from_yaml`.
4. In `_request`, pass `headers=` if tokens are static or `headers_factory=` if they refresh mid-cycle.
5. Add a `_parse_gitlab_provider` function and wire it in `__main__.py`.
6. Add tests in `tests/test_providers.py`.

### Testing patterns
- **Scanner tests** — mock full providers with `MagicMock()` (see `_mock_provider` helper in `test_scanner.py`).
- **Provider tests** — mock `provider._request` directly, not `requests.request`.
- **Config tests** — use `tmp_path` fixture + `monkeypatch.setenv("CONFIG_PATH", path)`.
- Tests are plain classes with descriptive method names; no pytest markers are used.

### Docker
Config is mounted at `/etc/pr-generator/config.yaml` (the default `CONFIG_PATH`). The container runs as non-root user `prgen`. `requirements.txt` drives the Docker build; `pyproject.toml` is the authoritative dependency source — keep both in sync when adding dependencies.
62 changes: 62 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 10
labels:
- enhancement
- dependency-management
assignees:
- devops-ia/devops-ia
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: chore
include: scope
rebase-strategy: auto
pull-request-branch-name:
separator: "-"
- package-ecosystem: pip
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 10
labels:
- enhancement
- dependency-management
assignees:
- devops-ia/devops-ia
groups:
pip:
patterns:
- "*"
commit-message:
prefix: chore
include: scope
rebase-strategy: auto
pull-request-branch-name:
separator: "-"
- package-ecosystem: docker
directory: "/"
schedule:
interval: monthly
open-pull-requests-limit: 10
labels:
- enhancement
- dependency-management
assignees:
- devops-ia/devops-ia
groups:
docker:
patterns:
- "*"
commit-message:
prefix: chore
include: scope
rebase-strategy: auto
pull-request-branch-name:
separator: "-"
154 changes: 154 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
name: Build and Push Docker Image

permissions: {}

env:
DOCKERHUB_USER: devopsiaci
DOCKERHUB_REPO: pr-generator
GHCR_REGISTRY: ghcr.io
GHCR_REPO: ${{ github.repository }}

on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
test:
name: Test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements.txt

- name: Install dependencies
run: pip install -r requirements.txt pytest

- name: Run tests
run: python -m pytest tests/ -v

release:
name: Release
needs: [test]
# Only run on direct pushes to main (not on pull requests)
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
attestations: write
contents: write
id-token: write
issues: write
packages: write
pull-requests: write

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Semantic Release
id: semantic
uses: cycjimmy/semantic-release-action@v6
with:
tag_format: 'v${version}'
extra_plugins: |
@semantic-release/changelog
@semantic-release/git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Set Docker metadata
id: meta
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKERHUB_USER }}/${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_REPO }}
labels: |
org.opencontainers.image.maintainer=adrianmg231189@gmail.com
org.opencontainers.image.title=PR Generator
org.opencontainers.image.description=PR Generator to automate pull request management
org.opencontainers.image.vendor=devops-ia
tags: |
type=raw,value=${{ steps.semantic.outputs.new_release_git_tag }}

- name: Set up QEMU
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/setup-qemu-action@v4

- name: Set up Docker Buildx
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/setup-buildx-action@v4

- name: Cache Docker layers
if: steps.semantic.outputs.new_release_published == 'true'
uses: actions/cache@v5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-

- name: "[DOCKERHUB] Log in"
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

- name: "[GHCR] Log in"
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
id: push
if: steps.semantic.outputs.new_release_published == 'true'
uses: docker/build-push-action@v7
with:
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
context: .
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
push: true
sbom: true
tags: ${{ steps.meta.outputs.tags }}

- name: "[DOCKERHUB] Update registry description"
if: steps.semantic.outputs.new_release_published == 'true'
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: ${{ env.DOCKERHUB_USER }}/${{ env.DOCKERHUB_REPO }}

- name: "[GHCR] Generate artifact attestation"
if: steps.semantic.outputs.new_release_published == 'true'
uses: actions/attest-build-provenance@v4
with:
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.GHCR_REPO }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

- name: Move Docker cache
if: steps.semantic.outputs.new_release_published == 'true'
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
19 changes: 19 additions & 0 deletions .github/workflows/github-auto-assign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Auto-assign Issue

on:
issues:
types: [opened]
pull_request_target:
types: [opened, ready_for_review]

jobs:
auto-assign:
permissions:
contents: read
issues: write
pull-requests: write
uses: devops-ia/.github/.github/workflows/github-auto-assign.yml@main
with:
teams: devops-ia
secrets:
PAT_GITHUB: ${{ secrets.PAT_GITHUB }}
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/

# Testing / coverage
.coverage
coverage.json
coverage.xml
htmlcov/
.pytest_cache/

# Env
.env
*.env
venv/
.venv/

# IDE
.vscode/
.idea/
Loading