Sign inSign up

cenk1cenk2/hermes-bridge-gitlab

By cenk1cenk2

•Updated 1 day ago

Bridges GitLab merge request and note hooks to Hermes runs.

Image
0

10K+

cenk1cenk2/hermes-bridge-gitlab repository overview

⁠hermes-bridge-gitlab

Bridges GitLab merge request and note webhooks that address the bot to Hermes runs, one thread per merge request or issue, and answers in the merge request or issue: an award, one status note edited in place and a reply in each discussion that asked. Talks directly to a Hermes⁠ API server — any deployment that exposes /v1/runs.

⁠Image

docker run --rm -p 8080:8080 cenk1cenk2/hermes-bridge-gitlab:latest
TagSource
latestThe latest release.
v<version>The release @kilic.dev/hermes-bridge-gitlab@<version> created by semantic-release.

⁠Environment

Durations are milliseconds unless noted.

VariableDefaultDescription
PORT8080Port the HTTP server binds to.
AGENT_NAMEHermesName the agent goes by in its status notes and replies, e.g. labrat.
LOG_LEVELinfoLowest level logged: error, warn, info, debug or verbose.
LOG_FORMATjsonjson for one JSON object per line, text for readable local lines.
REDIS_URLrequiredRedis or Valkey holding every queue and all shared state.
QUEUE_PREFIXhermes-bridge-gitlabPrefix of every Redis key and queue.
QUEUE_RETENTION_COMPLETED3600Seconds a completed job is kept.
QUEUE_RETENTION_FAILED86400Seconds a failed job is kept.
QUEUE_TRANSACTION_RETRIES10Attempts of a Redis transaction that keeps conflicting.
GITLAB_URLrequiredBase URL of the GitLab instance, e.g. https://gitlab.example.com.
GITLAB_TOKENrequiredPersonal access token of the bot (api scope) for awards, notes and merge request reads.
GITLAB_WEBHOOK_TOKENrequiredSecret token of the project hooks, compared with X-Gitlab-Token.
GITLAB_BOT_IDrequiredGitLab user id of the bot; its own notes and merge request events are dropped, and it is the assignee or reviewer that starts a fix or a review.
GITLAB_BOT_USERNAMErequiredUsername a note mentions to address the bot, and @<username> stop stops its thread.
GITLAB_WEBHOOK_BODY_LIMIT4194304Largest webhook body in bytes.
GITLAB_WEBHOOK_TIMEOUT4000Time a webhook may spend queueing its event before it answers 503.
GITLAB_WEBHOOK_TTL86400000Lifetime of seen deliveries and stops.
GITLAB_WEBHOOK_INSTRUCTIONS_DEFAULTnoneInstructions of an input whose kind has none of its own.
GITLAB_WEBHOOK_INSTRUCTIONS_REVIEWnoneInstructions of a review input.
GITLAB_WEBHOOK_INSTRUCTIONS_FIXnoneInstructions of a fix input.
GITLAB_WEBHOOK_INSTRUCTIONS_MENTIONnoneInstructions of a mention input.
GITLAB_WEBHOOK_ALLOWED_USERSComma-separated GitLab usernames allowed to trigger the bot by a mention or @<username> stop. Anyone else is dropped without an award, a note or a run. Assigning the bot or requesting its review is an explicit request and bypasses the list. Empty allows everyone.
GITLAB_CLIENT_TIMEOUT10000Timeout of one GitLab API call.
GITLAB_CLIENT_PAGES10Pages of 100 discussions searched for a note whose hook carried no discussion_id.
GITLAB_NOTE_RATE_LIMIT_MAX1Awards, notes and edits posted per rate limit window, across replicas.
GITLAB_NOTE_RATE_LIMIT_DURATION1000Length of the rate limit window.
GITLAB_NOTE_DEBOUNCE10000Delay that collects progress and the streamed answer into one edit of the status note.
GITLAB_DISCUSSION_TTL86400000Lifetime of a status note's state, a thread's progress and its last stop note.
GITLAB_RECOVERY_AGE86400000Age of the oldest open status note the boot sweep resumes or closes.
GITLAB_RECOVERY_GRACE60000Age a status note needs before the boot sweep touches it.
HERMES_URLrequiredBase URL of the Hermes API server, e.g. https://hermes.example.com/v1.
HERMES_API_KEYrequiredKey sent to the Hermes API server.
HERMES_INSTRUCTIONSnoneInstructions of a run whose input carries none, that is when neither GITLAB_WEBHOOK_INSTRUCTIONS_<KIND> nor GITLAB_WEBHOOK_INSTRUCTIONS_DEFAULT is set.
HERMES_EVENTS_IDLE_TIMEOUT120000Silence on a run event stream before it is dropped and the run is polled.
DRIVER_SEEN_TTL86400000Lifetime of a seen idempotency key.
DRIVER_QUEUE_LOCK_TTL30000Lifetime of a thread lock.
DRIVER_QUEUE_LOCK_RENEW_INTERVAL10000Renewal interval of a held thread lock.
DRIVER_QUEUE_SWEEP_INTERVAL1000Interval of the sweep for threads with due work.
DRIVER_BACKOFF_INITIAL5000First retry delay while a run create gets 429, a 5xx or no connection.
DRIVER_BACKOFF_MAX60000Largest retry delay.
DRIVER_POLICY_BATCHtrueJoin the inputs queued behind a run into one run.
DRIVER_POLICY_STEERtrueSteer a follow-up into the running run instead of queueing it.
DRIVER_POLICY_RESUBMIT_MAX0New runs in the same Hermes session for a run that ends interrupted.
DRIVER_POLICY_BACKOFF_WINDOW600000Time Hermes may stay busy or unavailable before the thread fails.
DRIVER_POLICY_APPROVALdeny-stopdeny-stop stops a run after denying its approval request, deny-continue lets it go on.
DRIVER_FOLLOW_LEASE_TTL30000Lifetime of a run lease; another replica takes the run over once it expires.
DRIVER_FOLLOW_LEASE_RENEW_INTERVAL10000Renewal interval of a held run lease.
DRIVER_FOLLOW_SWEEP_INTERVAL5000Interval of the sweep for runs nobody follows.
DRIVER_FOLLOW_POLL_INTERVAL5000Interval between polls of a run without a stream.
DRIVER_FOLLOW_RETENTION86400000Time a finished run's record is kept.
DRIVER_HEARTBEAT_IDLE1200000Silence in a thread before it reports idle.
DRIVER_HEARTBEAT_SWEEP_INTERVAL60000Interval of the idle sweep.
SLACK_ENABLEDfalsePost a Slack message for every run that ends with an answer.
SLACK_TOKENrequired when enabledBot token of the Slack app (chat:write scope).
SLACK_CHANNELrequired when enabledChannel id the messages go to; the bot must be a member of it.
SLACK_TTL86400000Lifetime of the posting claim that deduplicates each run across replicas.
SLACK_ANSWER_LIMIT3000Characters of the answer quoted in the message, cut at whole lines.
SLACK_CLIENT_TIMEOUT10000Timeout of one Slack API call.
SLACK_CLIENT_RETRIES3Retries of a Slack API call that fails or is rate limited.
BOARD_ENABLEDfalseDeliver thread state, source metadata and independent board/post reporting flags to the agent runs receiver; per-thread flags do not disable transport.
BOARD_URLrequired when enabledEndpoint the states are posted to, e.g. https://webhooks.kilic.dev/v1/hooks/hermes.
BOARD_TIMEOUT10000Timeout of one delivery POST or reconciliation status query.
BOARD_RETRIES3Retries of a post that fails or answers other than 2xx.
BOARD_REFRESH_INTERVAL60000Minimum interval between immediate working deliveries from real activity, shared across replicas; not the maintenance interval.
BOARD_RECONCILE_INTERVAL3600000Startup and hourly maintenance replay retained projections and repair known active runs; independent of event delivery and receiver rendering.
BOARD_TTL86400000Source lifetime of retained board projections and deduplication. Replays and status queries do not renew it.

⁠Slack

With SLACK_ENABLED, a run that completes with an answer posts one card to SLACK_CHANNEL: the title <agent> answered <project>!<iid> <title> on GitLab (# for an issue), a green bar, the answer quoted with its paragraph breaks kept and cut at whole lines to SLACK_ANSWER_LIMIT with a +more marker after a blank line when it is longer, link buttons to the thread the answer was posted in, when a note started the run, and to the merge request or issue, and the time it was posted. A run that fails, is stopped, has a command denied or completes without output posts nothing, and each run is posted once across replicas. A Slack failure is logged and never touches the GitLab replies. The buttons are plain links, so the Slack app needs no interactivity request URL.

Answer cards are post-only: no reaction polling, user lookups or manual-resolution edits run at startup or afterward. The bot needs only chat:write and channel membership for these posts. Posting claims remain in Redis under QUEUE_PREFIX for SLACK_TTL; replay after that claim expires can post again. This is not transactional exactly-once delivery across Slack and Redis.

Historical MANUALLY RESOLVED messages are not edited or deleted. Old card records, reaction indexes and resolution locks can remain inert in Redis; no data cleanup is required. Board reporting, its independent reporting controls and the run lifecycle are unchanged.

⁠Board

Reports include optional reporting: { board: boolean, post: boolean }: the receiver filters board visibility and agent announcement posts independently. Missing reporting or either missing flag means true. Both-disabled reports are still retained and delivered so the receiver can clear stale visibility; flags never suppress execution, results, errors or SSE. The resolved policy survives activity, terminal events, retries, outbox delivery and hourly reconciliation without renewing source expiry. Direct GitLab webhook threads default to { board: true, post: true }. This policy does not disable platform replies or the separate Slack answer cards.

With BOARD_ENABLED, every thread posts its state to BOARD_URL as JSON {"key","platform","sessionId","state","title","link","links","error","runId","at","sourceActivityAt","reporting"}, next to the GitLab replies. running means WORKING while a turn is queued or executing; done means IDLE immediately after completion, awaiting another message. Failures, stops and denied commands remain distinct as failed, stopped and approval_denied. Real tool, message, todo and thought activity refreshes running at most once per BOARD_REFRESH_INTERVAL across replicas; silence never changes an active turn to idle, and synthetic idle sweeps send no board updates. Working and completed entries expire from their last known source activity, not their last delivery; the next real turn reports working again. sessionId is optional and comes from the thread's Hermes session, key is the thread key, and title and link identify the merge request or issue. Optional links carries additional source links as [{"text","url"}] from input metadata when present; existing merge request/issue titles and primary links remain unchanged. Board state retains source metadata for activity updates without inputs, including updates from another replica. error carries the failure or denied command. at is an epoch-millisecond version that increases when a new projection is persisted; it orders updates, not receipt time. Optional sourceActivityAt is epoch milliseconds of the last known real accepted, queued, tool, message, thought, todo or terminal lifecycle activity. Even activity coalesced within BOARD_REFRESH_INTERVAL persists a new immutable projection with its source time. Silence, replay and unchanged status checks do not create activity or a fresh version. A repaired source change uses its known activity time, not query time. Settled and superseded occurrences reject late callbacks.

The complete latest projection and its pending delivery are stored atomically with a retained index, including terminal threads after driver cleanup. Driver lifecycle transitions await only Redis persistence; board delivery and platform/Slack HTTP remain detached. Immediate delivery keeps the BOARD_RETRIES budget. After releasing delivery ownership, the sender checks for a newer due pending version and drains it immediately, including reports persisted by another replica while delivery was finishing. The exhausted version itself waits for startup or hourly reconciliation rather than retrying in a tight loop. An acknowledgement clears only its exact version, so it cannot discard a newer report. Reconciliation also replays acknowledged latest projections to restore lost receiver history, byte-for-byte with the same at, source time, session, links, error and reporting policy. Redis token-owned locks coordinate replicas; at-least-once delivery can repeat an identical payload.

Receiver integration must deduplicate equal/older at without another announcement and expire entries at sourceActivityAt + HERMES_TTL, using only the remaining delay and ignoring expired replays. Receipt time is transport diagnostics, never source activity. Deploy that receiver support before enabling repeated replay; legacy JSON fields and the endpoint remain unchanged. Receiver rendering remains event-driven at 60 seconds, and daily message rotation belongs to the receiver, not this sender.

Active-run repair uses the established HERMES_URL and HERMES_API_KEY through GET /v1/runs/{id}; it never sends driver input, recovers a driver thread, or emits platform activities. The driver record and board version must still match the snapshot taken before the query. Explicit stop and denied-stop state override raw run status, while an interrupted run eligible for driver resubmission stays running. Hermes updated_at is seconds of a status write, not user activity: only a recognized activity/terminal event can supply a recovery source time, and interruption queries never use it because GET can synthesize it. Otherwise repair preserves a known board source time or the original follower start time; unknown or expired provenance is skipped. Terminal history with neither a retained projection nor a surviving driver source cannot be reconstructed, and no timestamps are invented for it. Existing refresh, retry and TTL setting names remain unchanged.

⁠Routes

MethodPathDescription
GET/healthzLiveness: answers 200 ok.
GET/readyzReadiness: answers 200 ok, and 503 once a shutdown starts draining.
POST/v1/hooks/gitlabGitLab project hooks for merge request, note and pipeline events: 400 on a wrong X-Gitlab-Token or a payload that fails the schema, 503 when the event could not be queued, else 200.
anyany otherAnswers 404.

⁠Events

Every event of the bot itself (GITLAB_BOT_ID) is dropped first, so nothing the bot writes starts a run. A delivery is handled once per Idempotency-Key (or webhook-id) header, and each input is sent once per note or merge request update.

EventConditionKind
Notenot create, a system note, not on a merge request or an issue, or no @<bot> in itdropped
Noteexactly @<bot> stopstop, never sent to Hermes
Notementions @<bot>mention
Merge requestclosed or mergedstop, when the thread has a run
Merge requestnot open, the bot newly an assignee or a reviewer, or its review re-requesteddropped, with a notice
Merge requestnot opendropped
Merge requestthe bot newly an assigneefix
Merge requestthe bot newly a reviewer, or its review re-requestedreview
Merge requestthe bot taken off and neither assignee nor reviewerstop, when the thread has a run

Each merge request or issue is one thread, gitlab-<project>-mr-<iid> or gitlab-<project>-issue-<iid>, which is also the Hermes session. An input carries the instructions of its kind, GITLAB_WEBHOOK_INSTRUCTIONS_<KIND> or else GITLAB_WEBHOOK_INSTRUCTIONS_DEFAULT; kinds with different instructions are never steered into each other's runs, and its meta {projectId, noteableType, iid, discussionId, noteId, kind} says where to answer.

⁠Feedback

Every call to GitLab goes through one BullMQ queue with a global concurrency of 1 and a shared rate limit, so replicas never race each other on a merge request.

  • A note that addresses the bot gets an eyes award at once, a stop note too.
  • Asking the bot for a fix or a review on a closed or merged merge request starts no run; it leaves a notice note instead, This merge request is <state>, so no <kind> run was started, once per update.
  • When a run is accepted, the thread opens one status note: a reply in the note's discussion for a mention, a new discussion for a review or a fix. The note is one sentence naming the ids to trace the thread by, <agent> has <state> the session through gitlab bridge, with key <key>, running in harness in session <session id> | run <run id>., where <agent> is AGENT_NAME, <key> is the thread key without its gitlab- prefix and <state> is accepted, turning failed or stopped when the run ends so. The answer, as Hermes streams it, follows that sentence and edits the note in place behind GITLAB_NOTE_DEBOUNCE, since edits notify nobody. A queued run, tool calls, todos and heartbeats stay off the note. A run a replica takes over is polled, so its note jumps from the partial answer to the final one.
  • The run's answer is a reply in the discussion of each of its inputs, once per discussion, and in the status discussion for a review or a fix, Done. for a run without one; the status note keeps only its sentence. A failure, a denied command and a stop are replies too, ending in the same sentence with failed (a denied command too) or stopped, and leave the partial answer below the sentence of the status note; a stop is also answered in the discussion of the stop note, and a stop of a run whose input has no discussion opens the status note to reply in. A stop note with nothing running is answered Nothing is running..
  • When the run ends, the bridge resolves the status discussion it started on a merge request, so it never blocks a merge; it never resolves a discussion an input came from, and issues have none to resolve. Review posts, Fixed in <sha>. replies, pushes and the resolution of review threads stay with the agent.
  • On boot, every status note left open is handed back to the driver; one whose run cannot be resumed gets a restart reply and is closed, once across replicas and restarts.

⁠Logs

Every line carries a message that is a complete sentence fixed per event, its source class as context, and its ids, numbers and reasons as fields at the root of the JSON object ({"level":"log","message":"Created a run for the thread.","context":"DriverThreadService","run":"...","thread":"...","replayed":false}); text prints the same fields inline. Durations are durationMs; tokens and bodies are never logged.

  • info: one line per HTTP request (method, path, status, duration, remote address; /healthz and /readyz only at debug), webhook verification or rejection with the Idempotency-Key and X-Gitlab-Event headers, each routed event and each dropped one with its reason (and, for a merge request that is not open, the kind it would have started and its state), queued notices, acknowledgement and thread input, skipped redeliveries, stops, opened status notes, posted replies, notices and awards, run creation, busy backoff, steers, follows and takeovers, finished runs with status and duration, Redis connections, the recovery sweep summary, the drain on shutdown and the runs and threads it hands over.
  • debug: every run stream event, Hermes and GitLab call (method, path, status, duration), queued award, note and edit, status note edit, thread lock and run lease acquire, renew and release, and Redis transaction retry.
  • warn and error: anything retried, dropped or failed, with its error.

⁠Fixtures

Run from apps/gitlab, tests/post-fixture.ts posts a fixture from tests/testdata to the webhook URL given as its second argument, with the webhook token, a fresh note id or updated_at and a fresh Idempotency-Key header. It runs on Node 26 as is, since Node strips the types:

GITLAB_WEBHOOK_TOKEN=... node tests/post-fixture.ts tests/testdata/note-merge-request.json https://bridge.example.com/v1/hooks/gitlab

⁠Development

The application lives in apps/gitlab of the workspace and runs on the shared core in packages/core; see the repository README.

pnpm install
pnpm lint
pnpm test
pnpm build
pnpm --filter @kilic.dev/hermes-bridge-gitlab start

⁠Automatic reviews

The existing token-guarded POST /v1/hooks/gitlab handles automatic chart and plan reviews as well as explicit requests. Pulumi owns project eligibility and sets X-Gitlab-Review-Kind: chart, pulumi or terraform on eligible labrat-events hooks. The token authenticates that provisioned metadata before any lookup or driver call. Unmarked projects do not start automatic reviews; JSON fields cannot select a job or override instructions. Keep the shared webhook token and hook configuration restricted to trusted administrators.

The bridge, not the gateway or runs broker, owns classification, skill selection, label exclusion and GitLab reporting. Chart reviews require open Renovate MR open/reopen/update hooks. Plan reviews require an open Renovate MR and a successful plan (Pulumi) or tf-plan (Terraform) build in an MR pipeline hook. Drafts and skip-CI titles are ignored. The same classifier preserves notes, assignments, review requests and lifecycle events. An explicit MR request takes precedence over automatic dispatch for that event.

The exact, case-sensitive automerge label excludes automatic reviews. Chart hooks use their top-level and MR attribute label objects without a GitLab lookup. Pipeline MR objects omit labels, so eligible pipeline deliveries read current labels through the existing client. The same read rejects closed, draft, skip-CI and stale-head targets. Flags and descriptions do not exclude reviews. Failed or incomplete required lookups answer 503 without driver or reporting side effects; accepted and ignored events answer 200 ok. A chart hook without either label list also fails closed. Delivered chart labels can be stale; this does not cancel work already accepted or predict labels added afterward.

The job, project, MR and full commit digest form the input identity (chart identity also includes source branch and title). The driver's atomic seen-key and inbox transaction deduplicates concurrent deliveries across replicas without a claim-before-enqueue crash gap. Retries after a failed lookup or enqueue remain eligible. Each automatic identity has its own gitlab-<project>-mr-<iid>-review-<digest> thread with batching and steering off. Existing GitLab discussion sinks own status notes, replies and completion reporting. Explicit note mentions, reviewer requests and assignments keep their behavior regardless of automatic exclusions.

GITLAB_WEBHOOK_BODY_LIMIT defaults to 4194304 bytes for large webhook descriptions. Generic runs remain platform-neutral and receive no GitLab credentials or admission logic.

Automatic and explicit events share this single endpoint. There is no per-job review controller or gateway automatic route. Keep direct Kargo/IaC job endpoints and generic runs independent. Deploy shared-endpoint support and consolidate Pulumi subscriptions before deploying removal of old automatic paths. Do not replay historical deliveries outside the driver deduplication retention window.

Tag summary

Content type

Image

Digest

sha256:962f9c312…

Size

72.1 MB

Last updated

1 day ago

docker pull cenk1cenk2/hermes-bridge-gitlab