GitLab and GitHub webhooks → one Telegram group. Cards that edit themselves instead of forty messages nobody reads.
Gitgram is a self-hosted bot that takes every project under a GitLab group, every repository of your GitHub organizations and accounts, or both at once, and turns their webhooks into Telegram messages that stay useful. A pipeline is one message that updates as jobs run, and a push is one message that carries its commits and the pipeline it triggered, so the group chat reads like a timeline instead of a log file. It covers pushes, pipelines, merge requests and published packages on GitLab, and pushes, Actions workflow runs, pull requests and published packages on GitHub; the other GitLab events are listed under Not supported yet.
The /preview command: mock cards going through the real engine, edited in place as their pipelines run.
It exists because Integram was archived, GitLab's built-in Telegram integration sends one flat line per event and calls it a day, and every other bot on GitHub either stopped in 2022 or only does DMs.
Single static Go binary. SQLite. No cgo. Runs happily in a 20 MB distroless container.
- Pipeline cards: pending → running → done, edited in place. Stage rows, job names, failed jobs with log links, durations, "waiting for manual job" states, child pipelines folded into the parent. Retries don't spawn a new card, they update the old one.
- Failure logs: when a job fails, the last 10 lines of its log appear under its stage, syntax-coloured. Running and passed jobs show no log, just the stage line and, once finished, how long it took. Needs
gitlab.read_token. - Artifacts: every archive a stage's jobs produced, linked with its size at the end of the stage's line, once the pipeline ends.
- One layout for every card, in plain Telegram HTML that any client renders: a title that never changes once posted,
@someone did this in repo (branch)(@ada pushed to agent (feat/x),@emi ran a pipeline in agent (main)), italic detail lines, one line per pipeline stage (✅ Build · 1:02,🏃 Deploy: running job x...,❌ Test: unit failed · 1:40with the failed log under it), and a quote for the commits, closed by their line counts. Emoji only as stage marks. - Stop, Retry, Run and Merge: a running pipeline carries
Stop pipeline, which asksYes, stop it/Keep runningbefore canceling; a failed one carriesRetry, no questions asked; a manual job waiting to start getsRun <job>. Needsgitlab.hooks_token; anyone in the group may press. Everything else is a link in the text. - Merge request cards:
@ada opened a MR in demo (feat/x → develop), the MR title in bold, the description and line counts in a fold, unresolved threads, the MR's head pipeline with its stage lines, and last, in bold,Merged into develop by @linus,Closed by @ada, or while openDraftand⚠️ Conflicts with develop. Deleting the MR's source branch addsThe branch x was deleted.under that line instead of posting a separate message. A merge request pipeline shows only there.Merge(with a confirmation) appears while GitLab reports the MR as mergeable. - Push cards:
@ada pushed to agent (feat/x), the commits in one quote asauthor: title, closed by the line counts (+23, -46 lines on 4 files), and the pipeline that push triggered, all edited in place. A newer push to the same branch gets its own card. Branch created and deleted, and a force-push warning (detected via the API, because GitLab doesn't tell you). - GitHub too: pushes, Actions workflow runs and pull requests from any number of organizations and user accounts, in the same cards and the same group as GitLab. A workflow run is a pipeline card with one line per job, a pull request is a merge request card. With a token: diff stats, failure logs, and Stop and Retry. See GitHub.
- Handles, not pings: people show as bold
@gitlab-userthat never links to a Telegram account, so a card never notifies anyone. - Reconciler: GitLab cards that stopped receiving events get re-read from the GitLab API. GitLab does not retry failed webhook deliveries, ever, so somebody has to.
sync-hooks: registers the webhook on every project in the group, or one group hook if you pay for Premium.- Everything else is a tap away through the links in the card; there are no link buttons.
/newbotat @BotFather, keep the token.- Add the bot to your group and make it an admin.
- Get the chat id (
-100…): forward a group message to @getidsbot, or open the group in web.telegram.org and read the URL fragment. - Optional: to have cards show no sender name, set the bot's name to an invisible one. In @BotFather send
/setname, pick the bot, and paste the three characters U+034F U+2005 U+034F (combining grapheme joiner, four-per-em space, combining grapheme joiner). They are between these brackets: [͏ ͏]. Telegram rejects an empty or plain-space name; this one passes and renders blank. To type it in a shell:printf '\u034f\u2005\u034f' | wl-copy(orxclip -selection clipboard).
| Token | Scope | Used for | Required |
|---|---|---|---|
GITGRAM_GITLAB_TOKEN |
read_api |
diff stats, failure logs, artifacts, force-push detection, reconciler, package cards | no, features degrade gracefully |
GITGRAM_GITLAB_HOOKS_TOKEN |
api |
gitgram sync-hooks and the Stop, Retry and Run buttons |
only for sync-hooks and buttons |
GITGRAM_WEBHOOK_SECRET |
any string | X-Gitlab-Token on each delivery |
yes |
One token with api covers both. A group access token works too.
Set GITGRAM_GITHUB_OWNER to the users or organizations whose repositories are accepted (comma-separated), and GITGRAM_GITHUB_WEBHOOK_SECRET to the webhook secret. Then add a webhook on each organization, and on each repository of a user account (users have no account-wide webhooks):
- Payload URL
<GITGRAM_PUBLIC_URL>/webhook/github, content typeapplication/json, the same secret. - Events: Pushes, Workflow runs, Workflow jobs, Pull requests, Registry packages.
GITGRAM_GITHUB_TOKEN is optional. A fine-grained token with Contents read and Actions read/write on those repositories adds push diff stats, failure logs, and the Stop and Retry buttons on workflow runs. GitHub cards follow the GitLab layout: a workflow run is a pipeline card with one line per job, a pull request is a merge request card. They have no Merge or Run buttons, no artifacts and no thread counts, and the reconciler does not re-read them. A push that starts several workflows shows the first one on the push card and the others as their own cards. A published package (Docker image, npm, Maven, RubyGems, NuGet) gets one card per version with its install command; an image's card collects every tag pushed to the same digest. Untagged per-platform image manifests and packages not linked to a repository are ignored. With only GitHub configured, GITGRAM_GITLAB_GROUP and GITGRAM_WEBHOOK_SECRET can be left empty.
Prebuilt multi-arch images (amd64, arm64) live at ghcr.io/esauvisky/gitgram. No clone needed:
mkdir gitgram && cd gitgram
curl -fsSLO https://raw.githubusercontent.com/esauvisky/gitgram/main/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/esauvisky/gitgram/main/.env.example -o .env
# edit .env: token, chat id, group, public URL, secrets (openssl rand -hex 24)
docker compose up -d
docker compose exec gitgram /gitgram sync-hooks --dry-run
docker compose exec gitgram /gitgram sync-hooksPut a TLS-terminating reverse proxy in front of 127.0.0.1:8080 and set GITGRAM_PUBLIC_URL to whatever GitLab and GitHub can reach. With GITGRAM_TELEGRAM_MODE=polling Telegram needs no public URL at all; GitLab and GitHub still do.
Pin a version with VERSION=0.1.0 docker compose up -d. To build locally instead of pulling: clone the repo and docker compose up -d --build. Without Docker: set -a; . ./.env; set +a; go run ./cmd/gitgram serve --poll, with GITGRAM_DB pointing somewhere writable.
Everything comes from GITGRAM_* environment variables; under Docker Compose that is the .env file next to docker-compose.yml (start from .env.example). Every problem is reported in one go, not one per restart.
| Variable | Default | Meaning |
|---|---|---|
GITGRAM_TELEGRAM_TOKEN |
required | bot token |
GITGRAM_CHAT_ID |
required | target group (-100…), or several separated by commas; every card goes to each and is edited in each. The first is the primary, the one the bot's bookkeeping follows |
GITGRAM_DEBUG_CHAT_ID |
chat that receives gitgram preview mock cards instead of GITGRAM_CHAT_ID; /preview works there too |
|
GITGRAM_TELEGRAM_MODE |
webhook |
webhook or polling |
GITGRAM_TG_WEBHOOK_SECRET |
required in webhook mode | URL suffix and secret_token for Telegram updates |
GITGRAM_PUBLIC_URL |
required for webhook mode and sync-hooks | external base URL; GitLab delivers to /webhook/gitlab, GitHub to /webhook/github, Telegram to /webhook/telegram/<secret> |
GITGRAM_LISTEN |
:8080 |
listen address |
GITGRAM_GITLAB_URL |
https://gitlab.com |
instance URL |
GITGRAM_GITLAB_GROUP |
required unless GITGRAM_GITHUB_OWNER is set |
top-level group; subgroups included, other projects ignored |
GITGRAM_WEBHOOK_SECRET |
required with a group | X-Gitlab-Token value |
GITGRAM_GITLAB_TOKEN |
read_api token |
|
GITGRAM_GITLAB_HOOKS_TOKEN |
api token for sync-hooks and the buttons |
|
GITGRAM_GITHUB_OWNER |
GitHub users or organizations whose repositories are accepted, comma-separated; enables /webhook/github |
|
GITGRAM_GITHUB_WEBHOOK_SECRET |
required with an owner | GitHub webhook secret (checked against X-Hub-Signature-256) |
GITGRAM_GITHUB_TOKEN |
GitHub token for diff stats, failure logs, and Stop and Retry | |
GITGRAM_GITHUB_API_URL |
https://api.github.com |
API base; GitHub Enterprise uses https://<host>/api/v3 |
GITGRAM_LOG_LINES |
10 |
log lines shown for a failed job; 0 disables them |
GITGRAM_DB |
/data/gitgram.db |
SQLite file |
GITGRAM_LOG_LEVEL / GITGRAM_LOG_FORMAT |
info / text |
slog level; text or json |
- Free tier:
gitgram sync-hookswalks every non-archived project under the group and creates or updates a project hook. Re-run it when projects are added.--dry-runshows the plan. - Premium/Ultimate:
gitgram sync-hooks --group-hookregisters a single group webhook. Don't do both; every event arrives twice and you get to pay for deduplicating it.
Endpoint: POST <GITGRAM_PUBLIC_URL>/webhook/gitlab. Events to enable: Push, Job, Pipeline, Merge request. Other events are accepted and ignored.
GitLab has no webhook for the package or container registry, so with GITGRAM_GITLAB_TOKEN set the bot polls the group's registries every 5 minutes and posts what was published since, one card per package version or image digest. The first poll after the database is created only records what already exists. GitLab images record no publisher and most CI-uploaded packages none either; those cards say "Someone".
GitHub: POST <GITGRAM_PUBLIC_URL>/webhook/github, set up by hand as described in GitHub. sync-hooks is GitLab only.
- Every pipeline, merge request and push owns exactly one Telegram message. Events update stored state; a single sender renders the latest state and edits the message. Identical renders are skipped by hash, so Telegram never sees "message is not modified" and you never see a pointless edit.
- Edits are debounced 700 ms: a burst of job events becomes one edit. First send and final state go out immediately.
- Telegram allows about 20 messages per minute per group, and edits count. The sender rate-limits (1/s, 20/min) and honours
retry_after. A busy pipeline can delay other messages; it cannot drop them. - Cards whose message was deleted by a human are marked and left alone. The bot does not resurrect things.
- State and the outbox live in SQLite; restarts resume pending edits, and the reconciler catches up on anything GitLab forgot to deliver.
- Retention: delivery keys 7 days, finished cards 30 days.
gitgram preview sends a mock card of every kind and scenario to GITGRAM_DEBUG_CHAT_ID (or, without one, to every GITGRAM_CHAT_ID chat): pushes, branches, a pipeline going pending → running → failed with log tails and artifacts, a passing and a manual pipeline, a merge request opened → merged plus a closed draft, and a Docker image picking up a second tag next to an npm package. Cards go through the real engine and sender, so first sends, in-place edits and folding behave as in production. State lives in a temporary database, nothing touches GitLab or GitHub, and Telegram is never polled, so it runs beside a live serve: docker compose exec gitgram /gitgram preview. Pick scenarios with --scenario push,pipeline and pace them with --delay 4s. The same thing is one message away in the group: /preview, or /preview push pipeline.
These GitLab events were supported before the card redesign and need cards in the new layout before they come back. Until then the bot ignores them and sync-hooks no longer subscribes to them.
- Merge request comments, replying to the MR card
- Issue cards (opened, closed, confidential, assignees, labels)
- Issue comments, replying to the issue card
- Tag pushes
- Releases
- Deployments
GET /healthz pings SQLite. The container runs read-only as non-root, and gitgram healthcheck exists because distroless has no curl. On SIGTERM: stop accepting, drain 30 s, finish the in-flight Telegram call, close the database.
- Per-user GitLab tokens linked from a DM, encrypted at rest, so operations run as the person pressing them instead of the bot's token.
- Approve and merge from the MR card.
MIT.

