GitHub two-way sync
Links an open-git repository to a GitHub repository and keeps refs, issues, pull requests, comments, and reviews in sync in both directions.
Moving parts
| Piece | Location |
|---|---|
| Pure sync logic (API client, mappers, echo detection) | packages/github-sync |
| Webhook ingress | apps/web/app/api/github/webhooks/route.ts |
| Job queue + data access | apps/web/db/github-sync.ts |
| Sync handlers | apps/web/lib/github/sync/ |
| Worker daemon | apps/github-sync-worker |
Webhooks are verified and persisted synchronously, then processed
asynchronously off the github_sync_jobs queue. Nothing in the request path
talks to the GitHub API.
Creating the GitHub App
- Go to Settings → Developer settings → GitHub Apps → New GitHub App (or the org equivalent).
- Homepage URL: your app URL. Webhook URL:
https://<app-url>/api/github/webhooks. - Generate a Webhook secret and keep it.
- Repository permissions:
- Contents: Read and write
- Issues: Read and write
- Pull requests: Read and write
- Metadata: Read-only
- No organization or account permissions are needed. GitHub actors are read from webhook and API payloads, not from org membership lookups.
- Leave Request user authorization (OAuth) during installation unchecked. Per-user tokens come from the separate OAuth app below, not from this App.
- Subscribe to events:
Push,Create,Delete,Issues,Issue comment,Pull request,Pull request review,Pull request review comment,Repository,Installation,Installation repositories. - Generate a private key and download the
.pem.
Then set:
GITHUB_APP_ID=123456
GITHUB_APP_SLUG=your-app-slug
GITHUB_APP_WEBHOOK_SECRET=...
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
The private key may be a literal multi-line PEM or a single line with \n
escapes; both are accepted.
Sync stays fully disabled until all four are present, so leaving them unset is a supported configuration.
Local development
GitHub cannot reach localhost, so tunnel the webhook endpoint:
# smee
npx smee-client --url https://smee.io/<channel> --path /api/github/webhooks --port 3001
# or cloudflared
cloudflared tunnel --url http://localhost:3001
Point the App's webhook URL at the tunnel. Run the worker alongside the web app:
pnpm --filter github-sync-worker dev
Identity and attribution
Writes to GitHub prefer the acting user's own linked GitHub OAuth token, so their activity appears under their real account. When the actor has not linked GitHub, the App bot writes instead and appends an attribution footer naming the original author. The footer is stripped on the way back in, so it never accumulates and never registers as a remote edit.
Those per-user tokens come from the Better Auth GitHub provider, which is a
separate OAuth app from the GitHub App above and needs the repo scope:
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
Without these, nobody can link a GitHub account, so every write falls to the bot. That looks like working sync with the wrong author on every comment, which is why it is worth confirming before concluding the actor path is broken.
A linked account is not proof of write access on GitHub. If the contributor's
own token is refused, the write is retried as the bot and recorded in
github_actor_write_denials, surfaced under Settings → GitHub sync.
GitHub users with no open-git account are represented by shadow profiles:
user_profiles rows with is_shadow = true and no username, rendered from
external_login. They satisfy the non-null author foreign keys and are
upgraded automatically if that person later links their GitHub account.
Numbering
Issues and pull requests share one number space per repository on both platforms. For a linked repo GitHub is the authority: creating an issue or PR in open-git calls the GitHub API inline and adopts the number it returns. If GitHub is unreachable the create fails rather than allocating a number that would later conflict.
Divergence
Only fast-forward ref updates sync automatically. If both sides move the same
ref away from the last agreed sha, or an update is not a fast-forward, the link
is marked diverged and ref sync pauses for that repository. Resolve it from
Settings → GitHub sync by choosing which side wins; nothing is force-pushed
automatically.