pwforge is a bridge that synchronizes patch workflows between mailing lists (tracked by Patchwork) and code forges (currently GitHub). It converts mailing list patch series into pull requests and vice versa, forwarding comments, reviews, and CI results in both directions.
Many open source projects use mailing list patch workflows (via git send-email) while contributors increasingly expect GitHub-style pull
request workflows. Patchwork tracks patches submitted to mailing lists
but has no forge integration. pwforge bridges this gap so that:
- A patch series submitted to a mailing list automatically becomes a GitHub pull request.
- A pull request opened on GitHub automatically becomes a patch series on the mailing list.
- Comments, reviews, and CI results flow between both systems.
pwforge runs as a standalone HTTP server that receives webhooks from both Patchwork and GitHub. It processes events asynchronously through two internal queues (one per source) and performs the appropriate sync operations.
┌───────────────────┐
mailing list ───► │ Patchwork ingress │──► Patchwork
└───────────────────┘ │
│ webhook
▼
┌────────┐
│pwforge │
└────────┘
▲
│ webhook
│
GitHub
pwforge auto-discovers projects from the Patchwork API. For each
Patchwork project, it parses the web_url or scm_url field to
determine the corresponding forge repository. Optional per-project
overrides can be specified in the config file via [[project]]
TOML array sections.
Each project gets its own:
- Forge client instance (with project-specific owner/repo)
- Bare git mirror (at
mirror-path/<link_name>.git) - MLToForge and ForgeToML sync handlers
- Mailing list address (from Patchwork's
list_emailfield)
The project map is cached with a configurable TTL and refreshed lazily on the next webhook.
When Patchwork fires a series-completed webhook:
- pwforge fetches the series metadata from the Patchwork API.
- If the series already has a linked PR (stored in series metadata),
it skips. If this is a respin of a previous series (detected via
patchwork's
previous_seriespointer), it force-pushes to the same branch and posts an update comment on the existing PR. - Otherwise, it downloads the series mbox, creates a temporary git
worktree from the repo's default branch, applies the patches with
git am, pushes to a branch named{prefix}/{series_id}/{slug}, and creates a pull request. - The PR body includes the cover letter content (or single patch
commit message), a link back to the Patchwork series, and a
<!-- pwforge -->HTML comment marker. - The PR number and branch name are stored in the series metadata on Patchwork for future reference.
When Patchwork fires a patch-comment-created or
cover-comment-created webhook:
- pwforge checks if the comment originated from itself (via the
X-PWForge-Eventemail header stored in patchwork's comment headers). If so, it skips to prevent loops. - It finds the series associated with the comment, looks up the linked PR from the series metadata, and posts the comment on the GitHub PR.
- The PR body is built from the cover letter or single-patch commit message. Trailers from the submitter (Signed-off-by, etc.) are stripped. Diffstat sections are removed.
- Branch names are generated as
{prefix}/{hex_id}/{slug}where slug is a sanitized, truncated version of the series name.
When GitHub fires a pull_request webhook (opened or synchronized):
- pwforge checks if the PR's branch starts with the configured
prefix (e.g.
pwforge/). If so, this PR was created by pwforge itself and is skipped to prevent loops. - It fetches the PR ref, creates a worktree, and generates a patch
series via
git send-email. The patches include:- A cover letter (if more than one commit) with the PR title and sanitized body.
Reply-To:header pointing to the mailing list.X-PWForge-PR:header with the PR URL.X-PWForge-Branch:header with the branch name.- For respins (PR synchronize events): version numbering (
-v2,-v3, etc.) and--range-diffagainst the previous version. --in-reply-topointing to the v1 cover letter for threading.
- The PR body is sanitized: HTML comments are stripped, AI-generated sections (CodeRabbit, Copilot summaries) are removed.
When GitHub fires an issue_comment webhook:
- If the comment contains the
<!-- pwforge -->marker, it was posted by pwforge and is skipped. - The comment is sent as an email reply to the series cover letter
(or first patch) via
git send-email, with the commenter as the From address when their email is known.
When GitHub fires a pull_request_review webhook:
- The review body and all inline comments are collected.
- A single email is sent with the review state, body text, and all inline comments (each with file path and diff context).
When GitHub fires check_suite (completed) or check_run (created)
webhooks:
- CI check results are posted on each patch in the series via the Patchwork checks API.
- A summary email is sent to the mailing list with all check run names, statuses, and URLs.
Three mechanisms prevent infinite sync loops:
- Branch prefix check: PRs on branches starting with the
configured prefix (default
pwforge/) are recognized as pwforge-created and skipped during forge-to-ML sync. - HTML comment marker: All GitHub comments and PR bodies posted
by pwforge contain
<!-- pwforge -->. Comments containing this marker are skipped during ML-to-forge sync. - Email header check: All emails sent by pwforge include an
X-PWForge-Eventheader. When patchwork ingests such an email and fires a comment webhook, pwforge detects the header in the comment's stored headers and skips it.
The forge implementation is behind an interface so that other forges (GitLab, Gitea, etc.) can be added without changing the core sync logic.
Each forge implementation provides:
BaseBranch(): Default branch of the repository.RepoURL(): Clone URL (used for git push).WriteCredentials(path): Write auth credentials for git operations.MetaKeyPR(),MetaKeyBranch(): Metadata key names used to store forge references in Patchwork series metadata.PRRef(n): Full URL for a PR (used in metadata).PRRefSpec(n): Git refspec to fetch a PR.CreatePR(title, body, head, base): Create a pull request.PostComment(n, body): Post a comment on a PR.RepoKey(): Normalized repository identifier for webhook routing.
Each forge package registers itself via init():
models.RegisterForge("github", New, newWebhookParser, resolveRepo,
models.WithSetupHandler(SetupHandler))This provides:
- A constructor for per-project forge instances.
- A webhook parser factory (created once, shared across projects).
- A URL resolver to extract owner/repo from patchwork project URLs.
- An optional setup handler for interactive app registration.
Webhook parsing is decoupled from per-project forge instances. A
single parser is created at startup from the global config (API
client + webhook secret). It verifies signatures, parses event
payloads, and populates a ForgeEvent struct that includes a
RepoKey field for routing to the correct project.
For GitHub, the parser extracts repository.full_name from every
event payload and uses the go-github library's typed event structs.
Review and check suite events make additional API calls to fetch
inline comments and individual check runs.
Two authentication modes are supported:
- Personal Access Token (PAT): Simple bearer token. Configured
via
tokenin the[github]config section. - GitHub App: JWT-based auth via the
ghinstallationlibrary. Configured viaapp-id,private-key-file. Theinstallation-idis auto-discovered at startup by listing the app's installations via the App JWT API.
pwforge includes a /setup endpoint that implements the GitHub App
manifest flow for one-click app registration:
- Admin visits
/setupon the pwforge instance. - A form auto-submits a JSON manifest to GitHub with the correct
permissions (
contents:read,pull_requests:write,issues:write,checks:read) and webhook URL. - The user confirms the app name on GitHub.
- GitHub redirects back with a temporary code.
- pwforge exchanges the code via
POST /app-manifests/{code}/ conversionsand receives the app ID, private key (PEM), and webhook secret. - Credentials are written to the config directory on disk.
- The user installs the app on their repositories and restarts pwforge.
When fork-owner and fork-repo are configured (different from
owner/repo), pwforge pushes branches to the fork and creates
PRs against the upstream repository. The PR head reference is
prefixed with fork-owner: as required by the GitHub API.
All git operations use a bare mirror clone stored at the configured
mirror-path. The mirror is created on first use and updated via
git fetch --all --prune before each operation.
- Git operations use temporary credential files written before each operation and deleted immediately after.
GIT_CONFIG_GLOBAL=/dev/nullandGIT_CONFIG_SYSTEM=/dev/nullare set to prevent user/system git config from interfering.GIT_TERMINAL_PROMPT=0prevents interactive auth prompts.- SMTP passwords are stored in the mirror's git config (not logged).
All outgoing email is sent via git send-email, which handles
MIME encoding, header formatting, and SMTP delivery. Two modes:
- Patch series (
SendPatches): Usesgit send-emailwith--cover-letter,--range-diff, version numbering, and custom headers. The cover letter description is written to a temporary file. - Standalone emails (
SendEmail): For comments, reviews, and CI results. Writes headers and body to a temporary.emlfile and sends viagit send-email --from --subject.
Each sync operation creates a temporary worktree from the mirror, performs the work (apply patches, generate emails), and cleans up. This prevents interference between concurrent operations and avoids state leaks.
pwforge uses Patchwork's REST API v1.4 for:
- Fetching series, patches, covers, and comments.
- Updating series metadata (PR links, branch names).
- Creating CI checks on patches.
- Querying series by metadata (for finding linked PRs).
- Listing projects (for auto-discovery).
- Downloading series mbox (with retry on empty response).
The client uses DisableKeepAlives: true on the HTTP transport to
avoid connection reuse issues.
pwforge is configured via TOML files loaded from (in order):
/etc/pwforge.tomlpwforge.toml(current directory)$PWFORGE_TOML(if set)
Settings from later files override earlier ones. CLI flags take
precedence over any configuration file. Run pwforge --print-config
to generate a default configuration file.
listen = ":8080"
base-url = "https://patches.example.com/pwforge"
forge = "github"
queue-size = 10
[patchwork]
url = "https://patches.example.com"
token = "<api-token>"
# optional: restrict to a single project
#project = "my-project"
cache-ttl = 600
[github]
# either PAT:
#token = "ghp_xxx"
# or App (auto-discovered via /setup):
app-id = 123456
private-key-file = "/etc/pwforge/github-app.pem"
webhook-secret = "<secret>"
[smtp]
host = "localhost"
port = 25
encryption = "none"
from = "Pwforge <pwforge@patches.example.com>"
[sync]
ml-to-forge = true
forge-to-ml = true
[git]
mirror-path = "/var/cache/pwforge"
branch-prefix = "pwforge"
subject-prefix = "PATCH"
# optional per-project overrides
[[project]]
name = "grout"
owner = "DPDK"
repo = "grout"
subject-prefix = "PATCH grout"A local testbed (make local or ./local/run.sh) creates a tmux
session with:
- Patchwork HTTP server (
pw http, localhost:8888) - Patchwork SMTP ingress (
pw ingress, localhost:2525) - pwforge binary (auto-restart via inotifywait on rebuild)
- GitHub webhook forwarding via
gh webhook forward - Mail client (aerc, neomutt, or mutt)
- Git working directory for patch submission
The testbed builds patchwork-next from a local checkout (default
../patchwork-next, configurable via $PATCHWORK_NEXT_DIR),
initializes a SQLite database with a test project and users via
pw admin commands, and creates a GitHub repo (or reuses an
existing one).