Every new project at the agency needed a Bitbucket repo with the same setup: a private repo, three standard branches, a PR webhook back to the intranet bot, push restrictions on the main branches, two-approval requirement for merges to master and staging, and per-role merge permissions. Doing all of that by hand for every project meant juniors occasionally merged straight to master and PRs sometimes shipped without review. I wrote a Drupal module that did the whole setup automatically the moment a PM clicked Save on a new project node.
The problem
The team had a documented workflow: develop → staging → master, two approvals required, no direct pushes to the main branches, no force-pushing, no deletes. The documentation was good. The enforcement was inconsistent.
Every new project needed someone to remember to apply that workflow when they set up the repo. Under deadline pressure, with a senior engineer doing the click-through alongside everything else, steps got skipped. Sometimes a junior would be onboarded onto a project before branch restrictions were in place, would push to master because the rule wasn't there to stop them, and the next person to clone the repo would inherit the surprise. Not because anyone wanted to break the rules, but because the rules lived in a wiki rather than in the system.
The brief I gave myself was small: encode the workflow in code, hook it into the moment a project is created, and remove the human step entirely.
The architecture
The agency's intranet ran on Drupal 7, with a "site" content type that captured every client project. The natural place to hook automation was the moment that node was created. The module implements hook_node_insert, authenticates against Bitbucket using OAuth2 client credentials (no service-user impersonation, no per-user tokens), and then provisions the repo, webhook, branches, and restrictions in sequence.
Authentication
The module authenticates with Bitbucket using the OAuth2 client credentials grant. There's no service-account user, no personal token sitting in a config file, no impersonation of a human. The intranet has a Bitbucket OAuth app, the client ID and secret live in a .env file alongside the module, and every API call presents a freshly-issued bearer token. If someone steals an old token, it's already expired.
Provisioning
The repo name is slugified from the project node's title (or an explicit field if the PM has set one), lowercased and stripped of non-alphanumerics so it's a valid Bitbucket slug. The module creates the repo as private, then immediately adds a PR webhook that points back at the intranet, with a shared-secret query parameter so the intranet can verify the call is genuine. The webhook subscribes to every PR event the team cared about: created, updated, comment created/updated/deleted, approved, unapproved, rejected, fulfilled. From there the intranet bot handles the PR-as-a-conversation flow inside the agency's existing tools.
Branch restrictions
Three branches get created up front: master, staging, develop. Each one gets the same set of protections:
- Push-restricted to the admin group only — no direct pushes from developers, juniors, or reviewers
- Two approvals required for any merge to
masterorstaging - Delete-protected so nobody can wipe a main branch by accident
- Per-group merge permissions so the developer, junior, and reviewer groups merge through the right gates
These are the same rules that lived in the agency's documentation. The module's contribution wasn't writing them; it was making it impossible to skip them.
Key decisions
Encode the workflow, don't document it
Rules-as-docs work when humans remember them under pressure. Rules-as-code work whether anyone remembered them or not. Every protection the workflow asked for was already documented; the gain wasn't the policy, it was the enforcement. The module's value is that there's no longer a way to bypass it short of editing the module itself.
Hook the node lifecycle, not a separate UI
A standalone "provision a repo" admin page would have been another step in the workflow, and another step is another opportunity to forget. PMs were already creating project nodes; making the repo provision off that existing action meant zero new behaviour to learn. The integration is invisible until something goes wrong, which is what good plumbing is supposed to feel like.
OAuth2 client credentials, not a service user
The lazy option was to create a fake "deploy" Bitbucket user and reuse their personal access token. That works until it doesn't: the user's account gets disabled, password expires, MFA gets enforced. Using the OAuth2 app + client credentials grant meant no human account anywhere in the auth path, and tokens that expire automatically.
Outcomes
The outcome that mattered most: junior developers stopped occasionally landing in production by accident. Not because they got better at the rules, but because the rules were finally in the path. A senior reviewer no longer had to remember to set up restrictions before onboarding the next person; the protections were live from the moment the repo existed.
What I'd do differently
The OAuth credentials lived in a .env file on disk rather than in a Drupal admin form. Adding a config UI would let an ops engineer rotate the OAuth app credentials without touching the server, and would surface the integration in the admin tree where the rest of the system's settings already lived.
Drupal 7 reached end-of-life in January 2025, so the module is now a portfolio artifact rather than a live piece of infrastructure. Re-implementing the same pattern as a GitHub Actions workflow (or as a small webhook receiver in front of any modern repo host) would keep the idea alive: encode the workflow, run it on every new project, never trust a human to remember.