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
20 changes: 20 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Summary

<!-- Briefly describe what this PR does and why -->

## Changes

<!-- List the key changes made -->

-

## Testing

<!-- Describe how you verified the changes -->

- [ ] Tests pass locally (`python -m pytest tests/ -v`)
- [ ] Pre-commit hooks pass (`pre-commit run --all-files`)

## Related Issues

<!-- Link any related issues: Fixes #123, Closes #456 -->
27 changes: 27 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
version: 2

updates:
# Python (pip) dependencies
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
commit-message:
prefix: "chore(deps)"

# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "ci(deps)"
130 changes: 130 additions & 0 deletions .github/scripts/generate_pr_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Generate PR body from git history."""

import os
import subprocess
import sys


def run_cmd(cmd: list[str]) -> str:
"""
Run command and return stdout.

Note: Commands use validated inputs only. The BASE_REF is validated
in main() before being used in any git commands.
"""
result = subprocess.run( # nosec B603 # noqa: S603
cmd,
capture_output=True,
text=True,
check=True,
shell=False, # Explicit: never use shell
)
return result.stdout.strip()


def categorize_files(files: list[str]) -> dict[str, int]:
"""Categorize changed files."""
categories = {
"src": 0,
"test": 0,
"doc": 0,
"ci": 0,
}

for f in files:
if f.startswith("src/"):
categories["src"] += 1
elif f.startswith("tests/"):
categories["test"] += 1
elif f.endswith("README.md") or (".github/" in f and f.endswith(".md")):
categories["doc"] += 1
elif ".github/workflows/" in f or ".github/dependabot" in f:
categories["ci"] += 1

return categories


def build_summary_line(categories: dict[str, int], total: int) -> str:
"""Build human-readable summary of changes."""
parts = []
if categories["src"] > 0:
parts.append(f"{categories['src']} source file(s)")
if categories["test"] > 0:
parts.append(f"{categories['test']} test file(s)")
if categories["doc"] > 0:
parts.append(f"{categories['doc']} doc file(s)")
if categories["ci"] > 0:
parts.append(f"{categories['ci']} CI file(s)")

if parts:
return f"This PR touches {', '.join(parts)} across {total} file(s) total."
return f"This PR modifies {total} file(s)."


def main() -> int:
"""Generate PR body from git history."""
base_ref = os.environ["BASE_REF"]

# Validate BASE_REF to prevent command injection
# Valid git refs: alphanumeric, hyphens, underscores, slashes, dots
# Reject anything suspicious
import re

if not re.match(r"^[a-zA-Z0-9/_.-]+$", base_ref):
print(f"ERROR: Invalid BASE_REF format: {base_ref}", file=sys.stderr)
return 1

# Additional safety: reject refs that could be command injection attempts
if base_ref.startswith("-") or ".." in base_ref:
print(f"ERROR: Suspicious BASE_REF detected: {base_ref}", file=sys.stderr)
return 1

# Get merge base
merge_base = run_cmd(["git", "merge-base", f"origin/{base_ref}", "HEAD"])

# Get commit messages
commits = run_cmd(["git", "log", "--pretty=format:- %s", f"{merge_base}..HEAD"])

# Get changed files
diff_stat = run_cmd(["git", "diff", "--stat", f"{merge_base}..HEAD"])
files = run_cmd(["git", "diff", "--name-only", f"{merge_base}..HEAD"]).split("\n")
files = [f for f in files if f] # Remove empty strings

# Categorize
categories = categorize_files(files)
summary = build_summary_line(categories, len(files))

# Build body
body = f"""## Summary

{summary}

## Changes

{commits}

<details>
<summary>Diff stats</summary>
```
{diff_stat}
```

</details>

## Testing

- [ ] Tests pass locally (`python -m pytest tests/ -v`)
- [ ] Pre-commit hooks pass (`pre-commit run --all-files`)

## Related Issues

<!-- Link any related issues: Fixes #123, Closes #456 -->
"""

print(body)
return 0


if __name__ == "__main__":
sys.exit(main())
65 changes: 65 additions & 0 deletions .github/workflows/auto-populate-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Auto-populate PR Body

on:
pull_request:
types: [opened]

permissions:
pull-requests: write

jobs:
populate-body:
name: Generate PR Body
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Fetch base branch
run: git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1

- name: Check if PR body needs population
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail

CURRENT_BODY=$(gh pr view "$PR_NUMBER" --json body -q '.body')

# Strip HTML comments, headers, whitespace, checklist items
STRIPPED=$(echo "$CURRENT_BODY" \
| sed 's/<!--.*-->//g' \
| sed '/^## /d' \
| sed '/^- \[.\]/d' \
| sed '/^-$/d' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| sed '/^$/d')

if [[ -n "$STRIPPED" ]]; then
echo "PR body already has custom content. Skipping auto-populate."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "PR body is empty or template-only. Will generate content."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Generate and update PR body
if: steps.check.outputs.skip == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail

BODY=$(python3 .github/scripts/generate_pr_body.py)
gh pr edit "$PR_NUMBER" --body "$BODY"
echo "PR body has been auto-populated."
105 changes: 105 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-3.11-${{ hashFiles('requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-3.11-

- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Cache pre-commit hooks
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: pre-commit-

- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure

- name: Lint check with ruff
run: ruff check src/ tests/ apps/

- name: Type check with mypy
run: mypy src/ apps/

- name: Security check with bandit
run: bandit -c pyproject.toml -r src/ apps/

test:
name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
needs: lint
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }}
restore-keys: |
pip-${{ runner.os }}-${{ matrix.python-version }}-
pip-${{ runner.os }}-

- name: Install dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install -e .

- name: Run tests with coverage
run: |
set -euo pipefail
python -m pytest tests/ \
-v \
--cov=template_project \
--cov-fail-under=90 \
--cov-report=term-missing \
--cov-report=xml

- name: Upload coverage report
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
25 changes: 25 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Dependency Review

on:
pull_request:
paths:
- "requirements*.txt"
- "setup.py"
- "pyproject.toml"

permissions:
contents: read

jobs:
dependency-review:
name: Review Dependencies
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4

- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: always
Loading
Loading