"Here I am, brain the size of a planet, and they ask me to review pull requests." — Marvin the Paranoid Android
Marvin is a GitHub App that automates pull request hygiene. It validates PR titles, descriptions, and Linear issue links; auto-assigns reviewers; updates titles and bodies; and merges PRs — all configurable per repository.
- Features
- Prerequisites
- GitHub App setup
- Configuration
- Repository configuration (
.marvin.yaml) - PR body format
- Feature reference
- Local development
- Deployment
Every feature is opt-in and enabled per repository via that repository's own .marvin.yaml file (see
Repository configuration). Features are disabled by default,
and a repository with no .marvin.yaml has Marvin fully disabled on it.
| Feature | Description |
|---|---|
auto_assignee |
Assigns the PR opener as assignee if none is set |
auto_draft_labels |
Automatically manages Work in progress and Ready for review labels based on GitHub draft state |
auto_review_assign |
Requests reviewers from a configured team when the Ready for review label is added |
auto_approve |
Adds the Approved label and removes Ready for review once enough approvals are in |
auto_changes_required |
Adds the Changes required label and notifies via Slack when a review requests changes; removes it and re-requests the affected human reviewers when Ready for review is re-applied |
require_ai_review |
Blocks auto_review_assign until a recognized AI reviewer (CodeRabbit, Graphite, etc.) has reviewed the PR at least once, or reported completion via a success commit status; reverts to Work in progress and comments if triggered early |
auto_merge |
Merges the PR (squash) when the Merge 🚀 label is added and all checks pass |
update_title |
Corrects the PR title format (adds missing issue ID prefix, strips GitHub-generated noise) |
update_linear_link |
Auto-fills the Fixed issues section from the git branch name if it's empty |
check_title |
Validates that the title starts with ISSUE-ID: |
check_description |
Validates that the description is composed only of bullet points |
check_time_spent |
Validates that the Time spent section contains a valid float (e.g. 1.5 hours) |
check_linear_link |
Validates that a Linear issue URL is present and consistent with the title |
check_linear_project |
Validates that the linked Linear issue belongs to a project |
check_changelog |
Validates that the changelog file (CHANGELOG.md by default, overridable via check_changelog.file) was updated and references the PR number |
slack_notify |
Sends a Slack DM to the reviewer when they are requested |
auto_cap_report |
On merge, creates a Jira task from the Linear issue for capitalization tracking |
- Go 1.26+ (to build from source)
- A GitHub App (see GitHub App setup)
- A Linear workspace with an OAuth token (required for Linear features)
- A Slack bot token (required for Slack notifications)
- A Jira instance (required for
auto_cap_report)
-
Go to Settings → Developer settings → GitHub Apps → New GitHub App (or your organization's equivalent).
-
Fill in the basics:
- GitHub App name:
marvin(or any name you like) - Homepage URL: your repo URL
- Webhook URL: the URL where Marvin is running, e.g.
https://marvin.example.com/webhook - Webhook secret: generate a random string, save it as
GH_WEBHOOK_SECRET
- GitHub App name:
-
Permissions — set these to Read & write:
- Repository:
Checks,Contents,Issues,Pull requests - Repository:
Members→ Read-only
- Repository:
-
Subscribe to events:
Check runPull requestPull request review
-
Generate a private key (downloaded as a
.pemfile). This is yourGH_SECRET_KEY. -
After creation, note the App ID from the General settings page →
GH_APP_ID. -
Install the app on your organization/repositories. The Installation ID can be found in the webhook payload (
installation.id) or in the app's Advanced tab under recent deliveries →GH_INSTALL_ID.
-
Go to https://api.slack.com/apps → Create New App.
-
Use the From Manifest option and paste this manifest:
{
"display_information": {
"name": "Marvin",
"description": "Productivity bot to help tech employees",
"background_color": "#383738",
"long_description": "Marvin is an automation bot. He takes care of Github PRs and makes sure engineers are following standard format. He also notifies people on Slack when they have work to do."
},
"features": {
"bot_user": {
"display_name": "Marvin",
"always_online": false
}
},
"oauth_config": {
"scopes": {
"bot": [
"calls:write",
"im:write",
"incoming-webhook"
]
},
"pkce_enabled": false
},
"settings": {
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}- Install the app to your workspace and grab the Bot User OAuth Token →
MARVIN_SLACK_BOT_TOKEN.
-
Go to https://linear.app/settings/api → Create OAuth app.
-
Fill in the form:
- Name:
Marvin(or any name you like) - Callback URL:
https://your-marvin-url.com/linear/callback
- Name:
-
Once created, create a Developer Token for the app →
LINEAR_OAUTH_TOKEN.
The Marvin service itself (credentials, org-wide settings) is configured through environment
variables — copy config/local/marvin.env as a starting point. Per-repository settings (which
features are on, who reviews what) live in each repo's own .marvin.yaml instead; see
Repository configuration.
| Variable | Description | Example |
|---|---|---|
GH_APP_ID |
GitHub App ID | 12345678 |
GH_INSTALL_ID |
GitHub App installation ID | 87654321 |
GH_SECRET_KEY |
Path to the .pem private key file |
/app/secrets/gh-key/latest.pem |
GH_WEBHOOK_SECRET |
Webhook secret used to verify payloads | s3cr3t |
These apply across every repository Marvin is installed on. Per-repository settings — which
features are on, who reviews what — live in each repo's own .marvin.yaml instead; see
Repository configuration.
| Variable | Description | Example |
|---|---|---|
MARVIN_GITHUB_TO_SLACK |
Comma-separated github-handle:slack-user-id for Slack DMs |
octocat:U012345678 |
MARVIN_AI_REVIEWER_LOGINS |
Comma-separated extra AI-reviewer bot logins recognized by require_ai_review and auto_changes_required, in addition to the built-in coderabbitai[bot]/graphite-app[bot]/copilot-pull-request-reviewer[bot] (exact, case-insensitive match). Repos can add their own via .marvin.yaml's ai_review.reviewer_logins. |
my-custom-ai-bot[bot] |
MARVIN_AI_REVIEW_STATUS_CONTEXTS |
Comma-separated extra commit-status contexts accepted by require_ai_review as a completed AI review when no formal review is found, in addition to the built-in CodeRabbit (exact, case-insensitive match on the current HEAD). Repos can add their own via .marvin.yaml's ai_review.status_contexts. |
MyAIReviewer |
MARVIN_REPO_CONFIG_POLL_INTERVAL |
How often Marvin re-polls every installed repository's .marvin.yaml in the background. Webhook handling always reads from this cache and never fetches .marvin.yaml itself. |
5m (default) |
| Variable | Description | Example |
|---|---|---|
LINEAR_OAUTH_TOKEN |
Linear personal API key | lin_api_... |
LINEAR_WORKSPACE_SLUG |
Workspace slug visible in Linear issue URLs | my-company |
LINEAR_ISSUE_PREFIXES |
Comma-separated list of issue prefix shorthands | ENG,APP,BUG |
| Variable | Description | Example |
|---|---|---|
MARVIN_SLACK_BOT_TOKEN |
Slack bot OAuth token | xoxb-... |
| Variable | Description |
|---|---|
JIRA_HOST |
Jira instance base URL, e.g. https://yourorg.atlassian.net |
JIRA_API_KEY |
Base64-encoded email:api-token string |
JIRA_FIELDS |
Comma-separated key-value pairs for project and field IDs (see below) |
JIRA_FIELDS keys:
| Key | Where to find it |
|---|---|
ProjectKey |
GET <JIRA_HOST>/rest/api/latest/project |
ProjectID |
Same endpoint |
TaskIssueTypeID |
GET <JIRA_HOST>/rest/api/latest/issuetype |
EpicIssueTypeID |
Same endpoint |
StartDateCustomFieldKey |
GET <JIRA_HOST>/rest/api/latest/field — look for "name": "Start Date" |
InProgressTransitionID |
GET <JIRA_HOST>/rest/api/latest/issue/<KEY>/transitions |
DoneTransitionID |
Same endpoint |
Marvin can optionally connect to a SQL database (Postgres or MySQL) for future
long-term memory features. The client is created at startup and disabled when
DB_HOST is empty.
| Variable | Description | Default |
|---|---|---|
DB_HOST |
Database host. Empty disables the database client entirely. | (empty) |
DB_DRIVER |
postgres or mysql. Required when DB_HOST is set. |
(empty) |
DB_PORT |
Database port. | 5432 (postgres), 3306 (mysql) |
DB_USER |
Database user. | (empty) |
DB_PASSWORD |
Database password. | (empty) |
DB_NAME |
Database name. | (empty) |
DB_PARAMS |
Driver-specific params, e.g. sslmode:disable,connect_timeout:5. |
(empty) |
DB_MAX_OPEN_CONNS |
Max open connections. | 25 |
DB_MAX_IDLE_CONNS |
Max idle connections. | 5 |
DB_CONN_MAX_LIFETIME |
Max connection lifetime. | 30m |
DB_CONN_MAX_IDLE_TIME |
Max connection idle time. | 5m |
Marvin composes its SQL through goqu, so
services build dialect-agnostic query expressions and pkg/database renders
them for the configured driver.
Schema changes live as versioned SQL files under internal/migrations/<driver>/,
managed by Atlas. Marvin applies pending migrations at
startup whenever the database is enabled — there is nothing to run manually in
production.
To author a new migration locally:
# Install the Atlas CLI: https://atlasgo.io/getting-started
make migrate-diff driver=postgres name=add_something
make migrate-diff driver=mysql name=add_something # keep both in lockstepThe make migrate-diff target also re-runs make migrate-hash, which
regenerates the atlas.sum integrity file in each driver subdirectory.
A PwnBot-style game: anyone who finds a colleague's laptop unlocked types
/lock @theirhandle from the unlocked laptop. The caller (= the victim, since
the command is being sent from their Slack) loses a point, the mentioned user
(= the finder) gains one. The victim later receives a DM from Marvin telling
them what happened. Calling /lock with no argument shows an ephemeral
leaderboard (top 3 / bottom 3).
The endpoint is gated on the database: without DB_HOST, requests return
501 Not Implemented.
Slack app configuration
- Add a slash command with the request URL
https://<your-marvin>/marvin/_webhook/slack/lock. - Turn on "Escape channels, users, and links" — Marvin parses the
<@U12345|handle>form Slack sends with that setting enabled. - Bot token scopes:
chat:write,im:write,users:read.
Env vars
| Variable | Description |
|---|---|
MARVIN_SLACK_SIGNING_SECRET |
Slack app signing secret used to verify the X-Slack-Signature header. Required outside dev. |
Everything about how Marvin behaves on a specific repository — which features are on, who reviews
what — is declared in a .marvin.yaml file committed at the root of that repository, on its
default branch. There is no central per-repo config on Marvin's side anymore: repo owners
self-serve their own settings via a normal PR to their own repo, without touching Marvin's
deployment.
A repository with no .marvin.yaml has Marvin fully disabled on it — no comments, no checks, no
reviewer assignment.
Marvin always reads .marvin.yaml off the repository's default branch, never off a PR's head
branch. Otherwise a PR author could edit their own review rules inside the very PR being
reviewed (e.g. remove the reviewer requirement) and bypass review. .marvin.yaml is never fetched
from the webhook request path: a background poller periodically re-reads it for every repository
the GitHub App is installed on (every MARVIN_REPO_CONFIG_POLL_INTERVAL, default 5m) and
webhook handling only ever reads from that cache. Changes to .marvin.yaml take effect once
merged to the default branch, subject to that poll interval. If a repository's .marvin.yaml
becomes invalid or fails to load, Marvin keeps using the last known-good configuration and
comments on the pull request explaining why.
# .marvin.yaml, at the root of the repository
features:
- auto_merge
- auto_review_assign
- check_changelog
- require_ai_review
- slack_notify # still requires the central MARVIN_GITHUB_TO_SLACK mapping
reviewers:
default_team: platform # used when no rule below matches a changed file (optional)
rules:
- path: "go/**"
team: backend-team
- path: "py/**"
team: data-team
check_changelog:
file: docs/CHANGELOG.md # optional, defaults to CHANGELOG.md at the repo root
ai_review:
reviewer_logins: ["my-custom-ai-bot[bot]"] # extends the built-in + org-wide defaults, this repo only
status_contexts: ["MyAIReviewer"]featuresis the same list of feature names documented in Features and Feature reference — only where they're declared has changed.reviewers.rulesis a list of glob patterns (**supported, e.g.go/**,py/**) matched against every file changed in a PR. When a PR touches files matched by more than one rule, Marvin requests reviewers from the union of every matched team — this is what makesauto_review_assignwork across a monorepo with per-team subtrees.reviewers.default_teamis used as a fallback when no rule matches any changed file.check_changelog.fileoverrides which filecheck_changelogvalidates against; defaults toCHANGELOG.mdat the repo root when omitted.- An invalid or unparsable
.marvin.yamldisables Marvin for that repository (fails closed) rather than running with a partial configuration; check the Marvin service logs for the parse error.
When using validation features (check_description, check_time_spent, check_linear_link), Marvin expects PR bodies to contain specific sections. Use this template:
## Description
- First thing done
- Second thing done
## Time spent
1.5 hours
## Fixed issues
https://linear.app/your-workspace/issue/ENG-123Rules:
## Description— must contain only bullet points (-,*, or+). Checkboxes are allowed and will be stripped before the commit message.## Time spent— must contain a number followed byhourorhours(e.g.1 hour,2.5 hours,1,5 hours).## Fixed issues— must contain a valid Linear issue URL matching your configuredLINEAR_WORKSPACE_SLUGandLINEAR_ISSUE_PREFIXES.
Merges the PR using squash when the Merge 🚀 label is added. Waits for all status checks to pass.
- The commit title = the PR title
- The commit body = bullet points from the
## Descriptionsection - The PR number is appended to the commit
The PR must have no labels other than dependencies, hotfix, and Merge 🚀 to be merged.
Before requesting the merge, Marvin reads the PR's merge state from GitHub, because GitHub evaluates branch protections and rulesets after accepting a merge request and reports a rejection to nobody. If the PR has merge conflicts, is still a draft, or is out of date with its base branch, Marvin removes the label and comments saying which. If GitHub reports the PR as blocked while status checks are still running, Marvin keeps the label and retries when they finish. If it is blocked once every check is done — an unresolved conversation, a missing approval — Marvin removes the label and comments with the list of what the base branch requires.
When the Ready for review 👌 label is added, Marvin resolves which team(s) should review based
on the reviewers block of the repository's .marvin.yaml:
every changed file is matched against reviewers.rules, and Marvin pools reviewers from the
union of every matched team, falling back to reviewers.default_team when nothing matches. This
is what lets a single monorepo route reviews to different teams per subtree (e.g. go/** →
backend-team, py/** → data-team).
Within the resolved team pool, the algorithm assigns people with the smallest current review load (load = total additions across open PRs assigned to them). The number of reviewers to assign is derived from the branch's required approving review count — Marvin supports both classic branch protection rules and repository rulesets.
Automatically manages the Work in progress ⏳ and Ready for review 👌 labels based on GitHub's native draft state transitions:
- When a PR is opened as draft or converted to draft → adds Work in progress ⏳
- When a PR is converted to draft and already has Ready for review 👌 → removes Ready for review 👌
- When the Ready for review button is pressed (draft → ready) → removes Work in progress ⏳, adds Ready for review 👌, runs PR checks, and triggers
auto_review_assignif enabled
Non-draft PRs opened normally are not affected by this automation.
Manages the Changes required label lifecycle:
- When a review requests changes, Marvin adds the Changes required label and notifies via Slack (see
slack_notify). - When the Ready for review label is re-applied while Changes required is still present, Marvin removes Changes required and re-requests a review from the human reviewers whose latest review requested changes — excluding recognized AI reviewer bots (default:
coderabbitai[bot],graphite-app[bot],copilot-pull-request-reviewer[bot], extendable org-wide viaMARVIN_AI_REVIEWER_LOGINSor per-repo via.marvin.yaml'sai_review.reviewer_logins). - If the repository also has
require_ai_reviewandauto_review_assignenabled, this swap waits for the AI-review gate to pass first, so a re-request never fires ahead of the AI review check.
Gates auto_review_assign behind AI-reviewer confirmation:
- When the Ready for review label is added (manually, or via the native draft → ready transition), Marvin checks whether a recognized AI reviewer bot (default:
coderabbitai[bot],graphite-app[bot],copilot-pull-request-reviewer[bot], extendable org-wide viaMARVIN_AI_REVIEWER_LOGINSor per-repo via.marvin.yaml'sai_review.reviewer_logins) has submitted at least one review on the PR. The review does not have to be on the current commit — an AI reviewer legitimately skips re-reviewing commits that add no reviewable changes (e.g. base-branch merges), so we rely on the author to re-request a review when needed. - As a fallback, if no formal review is found, Marvin accepts a successful commit status whose context matches a known AI reviewer (default:
CodeRabbit, extendable org-wide viaMARVIN_AI_REVIEW_STATUS_CONTEXTSor per-repo via.marvin.yaml'sai_review.status_contexts) on the current HEAD. Some reviewers skip submitting a review on trivial/no-op diffs but still publish this completion status. - If neither is found, Marvin removes Ready for review, re-adds Work in progress, comments asking the author to request an AI review, and does not assign a human reviewer.
- If an AI review (or matching success status) is found,
auto_review_assignproceeds as normal.
Has no effect unless auto_review_assign is also enabled.
If the ## Fixed issues section is empty or missing a Linear URL, Marvin extracts the issue ID from the branch name (e.g. feature/eng-123-my-feature → ENG-123) and fills in the section automatically.
Corrects the PR title to the ISSUE-ID: description format. Also removes the noise GitHub adds from the branch name (e.g. ENG-42: Feature/eng 42 my title → ENG-42: my title).
Queries Linear to verify that the linked issue belongs to a project. Useful for enforcing that work is always tracked in a project.
On PR merge, queries Linear for the issue, then creates a Jira task for capitalization tracking. Requires both Linear and Jira to be configured.
cp config/local/marvin.env .env
# Fill in your valuesgo run ./cmd/marvinmake testmake mockgenThe webhook signature check is disabled when IS_DEV_ENV=true. You can grab real payloads from your GitHub App's Advanced page (under recent deliveries), then replay them:
curl -X POST http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request" \
-d @payload.jsonA pre-built Docker image is published to GitHub Container Registry on every merge to main:
docker pull ghcr.io/flashgap/marvin:latestThe deploy/ directory contains a Cloud Run deployment template. You will need to adapt the service account, VPC connector, and secret names to your own GCP project.
The app is configured entirely through environment variables — any container platform (Cloud Run, Fly.io, Railway, etc.) works.
If you want to make use of the labels to control the flow of a PR, make sure to create them in your repository:
jq -c '.[]' labels.json | while read -r label; do
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
/repos/OWNER/REPO/labels \
-f "name=$(echo "$label" | jq -r '.name')" \
-f "description=$(echo "$label" | jq -r '.description')" \
-f "color=$(echo "$label" | jq -r '.color')" \
> /dev/null
done