/merge
Force-merge a PR, sync local main, and push to the personal fork.
The user may pass a PR number as an argument (e.g. /merge 4764). If none is given, detect the open PR from the current branch.
Step 1 — Identify the PR and derive fork remote
gh pr view [<number>] --json number,title,body,headRefName,baseRefName,state
Confirm:
state is OPEN
baseRefName is main
If the PR is already merged or closed: print a message and stop.
Output one sentence describing what this PR does — plainest possible language, no jargon. Read the title and body to derive it. Say it as your first line of output so the user immediately knows you're merging the right thing.
Derive the personal fork remote:
GH_USER=$(gh api user -q .login)
FORK_REMOTE=$(git remote -v | grep -i "github.com[/:]${GH_USER}/" | head -1 | awk '{print $1}')
If FORK_REMOTE is empty, print a warning and ask the user to identify their fork remote with git remote -v.
When the user explicitly passed a PR number with the /merge command, proceed without asking for confirmation. Only ask if the PR number was auto-detected from the current branch (i.e. no argument was given).
Step 1b — Final review check for delayed comments
Before merging, do one last check for new reviews or comments that may have arrived since /pr completed (CodeRabbit and other bots sometimes submit delayed reviews):
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
echo "=== reviews ===" && \
gh api repos/$REPO/pulls/<number>/reviews \
--jq '[.[] | {user: .user.login, state: .state, body: .body}]' && \
echo "=== inline comments ===" && \
gh api repos/$REPO/pulls/<number>/comments \
--jq '[.[] | {user: .user.login, path: .path, line: .line, body: .body}]'
If any new unresolved review threads or comments exist: stop and ask the user whether to address them before merging.
Step 2 — Force-merge the PR
gh pr merge <number> --squash --admin --delete-branch
--admin: bypasses required review checks and branch protection rules
--squash: squash all commits into one on main
--delete-branch: deletes the remote branch after merge
If the merge fails: print the full error and stop. Do not proceed.
Step 3 — Delete the local branch (if applicable)
Check whether the branch exists locally:
git branch --list <headRefName>
If the branch exists and is not the currently checked-out branch: delete it.
git branch -d <headRefName>
If that fails (branch not fully merged in git's view due to squash): use force:
git branch -D <headRefName>
If we're currently on the feature branch: switch to main first, then delete.
git checkout main
git branch -D <headRefName>
Step 4 — Pull updated main
git fetch origin main
git reset --hard origin/main
After a squash merge, local main always diverges (N commits become 1 on origin), so --ff-only will always fail. reset --hard is correct and safe here — local commits are already represented in the squash on origin.
Step 5 — Push main to personal fork
git push $FORK_REMOTE main --force
Force-push is required after a squash merge because the fork's main still has the pre-squash commits.
Step 6 — Update linked issues on the project board
This repo's project board is SatcherInstitute org project number 5 (id PVT_kwDOBCaVcM4BeXhW), Status field id PVTSSF_lADOBCaVcM4BeXhWzhYyS0g, with options Backlog (f75ad846), Up Next (d6a0bd53), In Progress (47fc9ee4), Done (98236657).
GitHub's native "Item closed" project automation already moves an issue to Done the moment it closes via a recognized keyword (Closes #NNNN, Fixes #NNNN, etc). That case needs no action here — just identify it:
gh pr view <number> --json body,closingIssuesReferences \
--jq '{body, autoClosing: [.closingIssuesReferences[].number]}'
For every other bare #NNNN reference in the PR body that is not in autoClosing, read the issue and use judgement against the merged diff:
gh issue view <n> --json number,title,body,state
Also check for issues never mentioned in the body at all. A PR body can be completely silent on the issue it was tackled from (no Closes, no bare #NNNN — this has happened: PR #5129 resolved #5127 without referencing it anywhere). Text-scanning the body alone misses this, so cross-check the board directly:
gh api graphql -f query='{ organization(login: "SatcherInstitute") { projectV2(number: 5) { items(first: 100) { nodes { id fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } content { ... on Issue { number title state } } } } } } }' \
--jq '.data.organization.projectV2.items.nodes[] | select(.fieldValueByName.name == "In Progress") | .content'
For each "In Progress" issue, compare its title and scope against the merged PR's title and diff. A close title match or a diff that visibly implements the issue's stated scope is a candidate even with zero textual reference — use the same judgement as above (read the issue body, compare to the actual changed files) before closing it. Do not close on title similarity alone; confirm the diff actually does what the issue describes.
Step 7 — Confirm follow-up items with the user
Scan the PR body/diff and anything surfaced in Step 6 for deferred work: language like "follow-up", "out of scope", "revisit later", "TODO", "deferred," or a new problem noticed but not fixed while building this PR.
If candidates exist, ask the user with AskUserQuestion (multiSelect) before touching anything — this is a prioritization call, not a mechanical one:
gh issue edit <n> --add-assignee @me
gh api graphql -f query="mutation { updateProjectV2ItemFieldValue(input: { projectId: \"PVT_kwDOBCaVcM4BeXhW\" itemId: \"$ITEM_ID\" fieldId: \"PVTSSF_lADOBCaVcM4BeXhWzhYyS0g\" value: { singleSelectOptionId: \"d6a0bd53\" } }) { projectV2Item { id } } }"
Only run the assign/move mutation for items the user actually selected. If nothing reads as an obvious follow-up, skip this step and say so briefly rather than forcing a suggestion.
Step 7b — Refresh the ReadMe contributor recipes
If the merged PR touched frontend/src/ or frontend/.env*, invoke the update-recipes skill with the PR number (/update-recipes <number>). It syncs code tabs and highlights via scripts/readme_recipes/sync_recipes.py, flags prose drift, and skips itself when README_API_KEY is unset. Local main is already at the merge commit (Step 4), so it reads the merged code. Otherwise skip this step.
Step 8 — Flag a likely DAG rerun for backend data changes
Check which files the merged PR touched:
gh pr view <number> --json files --jq '.files[].path'
If any path is under python/datasources/ or python/ingestion/ and the change affects data output (not just a comment, type hint, or test-only change — read the diff if unsure), the merge likely needs a DAG rerun to actually refresh the deployed data, since merging alone only ships the code.
Map the touched datasource to its workflow: look for a .github/workflows/dag*.yml whose name or on.workflow_dispatch job references the same data source (e.g. age_adjust_cdc_hiv.py → dagCdcHiv.yml).
gh workflow list --all | grep -i dag
Ask the user before triggering anything:
"This PR touched <file>, which looks like it changes data output. Want me to rerun <dag>.yml against infra-test?"
If they decline or no backend-data files were touched, skip to Step 10.
Step 9 — Wait for the auto-deploy before rerunning the DAG
Merging to main automatically fires a DEPLOY MAIN CODE TO INFRA-TEST GCP (DEV SITE) workflow that redeploys the same het-infra-test-05 project the DAG will hit. Running the DAG before that deploy finishes hits the pre-merge container and fails or silently uses stale code — indistinguishable from the fix not having worked. Confirm the deploy has completed before triggering the DAG:
DEPLOY=$(gh run list --branch main --workflow "DEPLOY MAIN CODE TO INFRA-TEST GCP (DEV SITE)" --limit 1 --json databaseId,status,conclusion,createdAt)
DEPLOY_RUN_ID=$(echo "$DEPLOY" | jq -r '.[0].databaseId')
echo "$DEPLOY"
If status is not completed, wait for it with gh run watch "$DEPLOY_RUN_ID" launched via the Bash tool's run_in_background (the harness notifies on completion — don't sleep-poll) rather than proceeding immediately. Once it's done, trigger the DAG:
gh workflow run <dag>.yml --ref infra-test
Step 10 — Confirm
Print a summary:
"Merged PR #<number>. Local main is up to date with origin/main and pushed to $FORK_REMOTE/main. Board: <issues moved to Done, if any> <issues moved to Up Next, if any>. Recipes: <current/pushed/skipped>. DAG: <triggered/skipped/declined>."
Notes
--admin bypasses all branch protection rules including required reviews. Use only when you have confirmed the PR is ready.
- Never force-push to
origin/main (SatcherInstitute). The merge goes through gh pr merge, not a direct push.
- If the PR is on a non-main base branch: stop and warn the user.
- Step 6 only needs to run once per merge — don't re-derive the project/field/option IDs, they're fixed for this repo (same ones
/tackle uses).
- Step 8/9 only apply when the PR touches
python/datasources/ or python/ingestion/ with real data-output impact — most PRs (frontend, docs, skills) skip straight to Step 10.