Skip to content
Open
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
9 changes: 9 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers-extra/features/hatch:2": {
"version": "2.0.18",
"resolved": "ghcr.io/devcontainers-extra/features/hatch@sha256:79fc7fb2935a76732e6a72f81d674b7e6416570bba8b27c3c359bd28e9d03cd3",
"integrity": "sha256:79fc7fb2935a76732e6a72f81d674b7e6416570bba8b27c3c359bd28e9d03cd3"
}
}
}
16 changes: 16 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "flask-githubapp",
"image": "mcr.microsoft.com/devcontainers/python:3",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"github.vscode-github-actions"
]
}
},
"features": {
"ghcr.io/devcontainers-extra/features/hatch:2": {}
},
"postCreateCommand": ["./.devcontainer/post-create.sh"]
}
3 changes: 3 additions & 0 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

pip install -e .[test]
24 changes: 24 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Lint

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize]
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/python
steps:
- uses: actions/checkout@v6

- name: Install hatch
run: pipx install hatch

- name: Lint
run: hatch fmt --check
23 changes: 11 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# This workflow will publish to PyPI on release.
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Publish to PyPI

name: PyPI release

on: workflow_dispatch
on:
release:
types: [published]

jobs:
build:
name: Build distribution
name: Build distribution 📦
runs-on: ubuntu-latest

steps:
Expand All @@ -31,18 +30,18 @@ jobs:
with:
name: python-package-distributions
path: dist/
publish:
name: Publish to PyPI

publish-to-pypi:
name: >-
Publish Python 🐍 distribution 📦 to PyPI
needs:
- build
runs-on: ubuntu-latest

environment:
name: pypi
url: https://pypi.org/p/Flask-GitHubApp

url: https://pypi.org/p/flask-githubapp
permissions:
id-token: write
id-token: write # IMPORTANT: mandatory for trusted publishing

steps:
- name: Download all the dists
Expand Down
43 changes: 15 additions & 28 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,37 +1,24 @@
# This workflow will run packages tests using a variety of Python versions.
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: Python package test
name: Test

on:
push:
branches:
- main
pull_request:
types: [opened, synchronize]
push:
branches: [master]
branches:
- main

jobs:
test:

runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.x"]
python-requirements: ["test_requirements.txt", "test_requirements_flask_2.txt"]
exclude:
# Python 3.14+ no longer works with flask 2
- python-version: "3.x"
python-requirements: "test_requirements_flask_2.txt"

container:
image: mcr.microsoft.com/devcontainers/python
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ${{ matrix.python-requirements }}
- name: Test with pytest
run: |
pytest
- uses: actions/checkout@v6

- name: Install hatch
run: pipx install hatch

- name: Test
run: hatch test -ar
122 changes: 67 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,86 +1,102 @@
# flask-githubapp ![tests](https://github.com/bradshjg/flask-githubapp/actions/workflows/test.yml/badge.svg)
# flask-githubapp

Flask extension for rapid Github app development in Python, in the spirit of [probot](https://probot.github.io/)

GitHub Apps help automate GitHub workflows. Examples include preventing merging of pull requests with "WIP" in the title or closing stale issues and pull requests.

## Getting Started

### Installation
To install Flask-GitHubApp:

`pip install flask-githubapp`

Or better yet, add it to your app's requirements.txt file! ;)

> `flask-githubapp` requires Python 3.8+ and Flask 2+
> [!NOTE]
> `flask-githubapp` requires a supported Python version and Flask 2+

#### Create GitHub App
#### Create a GitHub App

Follow GitHub's docs on [creating a github app](https://developer.github.com/apps/building-github-apps/creating-a-github-app/).

> You can, in principle, register any type of payload to be sent to the app!

Once you do this, please note down the GitHub app Id, the GitHub app secret, and make sure to [create a private key](https://docs.github.com/en/developers/apps/authenticating-with-github-apps#generating-a-private-key) for it! These three elements are __required__ to run your app.

#### Build the Flask App

The GithubApp package has a decorator, `@on`, that will allow you to register events, and actions, to specific functions.
For instance,
The following example shows both styles of exposing event handlers, the functional and class style.

[`samples/log_event/app.py`](./samples/log_event/app.py)

```python
@github_app.on('issues.opened')
def cruel_closer():
#do stuff here
```
import base64
import logging
import os

Will trigger whenever the app receives a Github payload with the `X-Github-Event` header set to `issues`, and an `action` field in the payload field containing `opened`
Following this logic, you can make your app react in a unique way for every combination of event and action. Refer to the Github documentation for all the details about events and the actions they support, as well as for sample payloads for each.
You can also have something like
from flask import Flask

```python
@github_app.on('issues')
def issue_tracker():
#do stuff here
```
from flask_githubapp import GitHubApp, GitHubAppEventHandler

The above function will do `stuff here` for _every_ `issues` event received. This can be useful for specific workflows, to bring developers in early.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Inside the function, you can access the received request via the conveniently named `request` variable. You can access its payload by simply getting it: `request.payload`

You can find a complete example (containing this cruel_closer function), in the samples folder of this repo. It is a fully functioning flask Github App. Try to guess what it does!
def create_app(test_config=None):
app = Flask(__name__)

#### Run it locally
app.config["GITHUBAPP_ID"] = os.environ["GITHUBAPP_ID"]
app.config["GITHUBAPP_KEY"] = base64.b64decode(os.environ["GITHUBAPP_KEY_B64"])
app.config["GITHUBAPP_SECRET"] = os.environ["GITHUBAPP_SECRET"]

For quick iteration, you can set up your environment as follows:
if test_config:
app.config.update(test_config)

```bash
EXPORT GITHUBAPP_SECRET=False # this will circumvent request verification
EXPORT FLASK_APP=/path/to/your/flask/app.py # the file does not need to be named app.py! But it has to be the python file that instantiates the Flask app. For instance, samples/cruel_closer/app.py
```
github_app = GitHubApp(app)

This will make your flask application run in debug mode. This means that, as you try sending payloads and tweak functions, fix issues, etc., as soon as you save the python code, the flask application will reload itself and run the new code immediately.
Once that is in place, run your github app
@github_app.on("issue_comment.created")
def log_issue_comment():
comment_body = github_app.payload["comment"]["body"]
logger.info(comment_body)

```bash
flask run
```
LogIssue(github_app=github_app)

Now, you can send requests! The port is 5000 by default but that can also be overridden. Check `flask run --help` for more details. Anyway! Now, on to sending test payloads!
return app

```bash
curl -H "X-GitHub-Event: <your_event>" -H "Content-Type: application/json" -X POST -d @./path/to/payload.json http://localhost:5000

class LogIssue(GitHubAppEventHandler):
logger = logging.getLogger(__name__)

@property
def events(self):
return ["issues.opened"]

def call(self):
issue_body = self.payload["issue"]["body"]
self.logger.info(issue_body)

```

```python
@github_app.on("issue_comment.created")
def log_issue_comment():
...

class LogIssue(GitHubAppEventHandler):
@property
def events(self):
return ["issues.opened"]
...
```

#### Install your GitHub App
Are how handlers register themselves for events. The syntax is `<event>[.<action>]` where the event comes from the
`X-GitHub-Event` header and the action from the payload. The action is optional.

#### Run it locally

**Settings** > **Applications** > **Configure**
For local development, [smee.io](https://smee.io) is the recommended proxy. If you'd like to disable webhook validation,
you can supply `app.config["GITHUBAPP_SECRET"] = False`.

> If you were to install the cruel closer app, any repositories that you give the GitHub app access to will cruelly close all new issues, be careful.
```bash
EXPORT GITHUBAPP_SECRET=False # this will circumvent request verification
EXPORT FLASK_APP=/path/to/your/flask/app.py # the file does not need to be named app.py! But it has to be the python file that instantiates the Flask app. For instance, samples/cruel_closer/app.py
```

#### Deploy your GitHub App
#### Testing

Bear in mind that you will need to run the app _somewhere_. It is possible, and fairly easy, to host the app in something like Kubernetes, or simply containerised, in a machine somewhere. You will need to be careful to expose the flask app port to the outside world so the app can receive the payloads from Github. The deployed flask app will need to be reachable from the same URL you set as the `webhook url`. However, this is getting a little bit into Docker/Kubernetes territory so we will not go too deep.
See [`samples/log_event`](./samples/log_event/) which inclues pytest configuration and some simple tests.

## Usage

Expand All @@ -95,6 +111,9 @@ outside a hook context).

`installation_token`: The token used to authenticate as the app installation (useful for passing to async tasks).

> [!NOTE]
> These same attributes are also available as method to handlers that inherit `GitHubAppEventHandler`

## Configuration

`GITHUBAPP_ID`: GitHub app ID as an int (required). Default: None
Expand All @@ -107,10 +126,3 @@ verification.
`GITHUBAPP_URL`: URL of GitHub instance (used for GitHub Enterprise) as a string. Default: None

`GITHUBAPP_ROUTE`: Path used for GitHub hook requests as a string. Default: '/'

You can find an example on how to init all these config variables in the [cruel_closer sample app](https://github.com/bradshjg/flask-githubapp/tree/master/samples/cruel_closer)

#### Cruel Closer

The cruel_closer sample app will use information of the received payload (which is received every time an issue is opened), will _find_ said issue and **close it** without regard.
That's just r00d!
69 changes: 69 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "flask-githubapp"
dynamic = ["version"]
description = "Rapid GitHub App development in Python"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
keywords = []
authors = [
{ name = "James Bradshaw", email = "james.g.bradshaw@gmail.com" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
]
dependencies = [
"flask",
"github3.py",
]

[project.urls]
Documentation = "https://github.com/bradshjg/flask-githubapp#readme"
Issues = "https://github.com/bradshjg/flask-githubapp/issues"
Source = "https://github.com/bradshjg/flask-githubapp"

[project.optional-dependencies]
test = [
"pytest~=8.1",
"pytest-mock~=3.12",
"pytest-randomly~=3.15",
"pytest-rerunfailures~=14.0",
"pytest-xdist[psutil]~=3.5",
]

[tool.hatch.version]
path = "src/flask_githubapp/__about__.py"

[[tool.hatch.envs.hatch-test.matrix]]
python = ["3.10", "3.14"]
flask = ["2", "latest"]

[tool.hatch.envs.hatch-test]
extra-args = ["-vv"]
features = [
"test",
]

[tool.hatch.envs.hatch-test.overrides]
matrix.flask.dependencies = [
{ value = "flask~=2.0", if = ["2"] }
]

[tool.ruff.lint.extend-per-file-ignores]
"tests/**/*" = [
"SLF001", # allow mocking private interfaces (for better and worse)
"INP001", # tests are an implicit package
]
"samples/**/*" = [
"INP001", # samples are not packages
]
Loading