Skip to content

KamalF/hermes

Repository files navigation

Hermes — Redmine ↔ Planka Sync

A stateless Python service that synchronizes Redmine tickets to Planka cards. Runs as a Kubernetes CronJob, polling both systems and reconciling state based on activity logs.

Redmine is the source of truth. Human changes on either side are detected via bot user identity in activity logs — no persistent state required.

How It Works

Each run:

  1. Discovers Planka boards where the bot user is a member
  2. For each board, scans for cards with [#ID] prefix and fetches the corresponding Redmine tickets
  3. If Gerrit is configured, fetches live label votes and reviewer emails for each ticket with a Gerrit Review URL (aggregated across the change series)
  4. Reconciles based on who made the last change (within a lookback window of CRON_INTERVAL_MINUTES * 2):
    • Human changed Redmine → updates Planka card (list, title, assignee, tester)
    • Human moved Planka card → writes new status to Redmine if the destination list matches a known Redmine status (regardless of where the card came from, including when the current Redmine status has no corresponding Planka list); otherwise leaves the card as-is. Always syncs member changes back to Redmine assignee/tester fields.
    • Both sides changed → the side with the more recent timestamp wins (tiebreaker)
    • Both sides bot-changed → no-op
    • Always (regardless of case): syncs Gerrit reviewers as card members and upserts the label block in the card description
  5. Posts a warning comment on cards referencing invalid Redmine ticket IDs (deduplicated)

Data Mapping

Redmine field Planka field
Issue ID Card name prefix [#ID]
Subject Card title (after prefix)
Status List (matched by name, case-insensitive prefix)
Assignee Card member (matched by email)
Tester (custom field) Card member (matched by email)
Card description (Redmine ticket link inserted at top)
Gerrit Review (custom field) Card description Gerrit Review link
— (Gerrit live data) Card description **Gerrit Labels** block with current votes
— (Gerrit live data) Card members: Gerrit voters and commenters (resolved by email)

Status mapping is implicit: Planka list names must match Redmine status names (case-insensitive prefix — a list named In Progress — Dev matches the status In Progress). The bot adds assignees, testers, and (if Gerrit is configured) Gerrit reviewers as card members; it removes bot-added members when they are no longer in any of those roles. Human-added members are never removed. The bot also maintains a Redmine ticket link (and Gerrit Review link + label block if set) at the top of each card's description.

Requirements

  • Python 3.13+
  • uv for dependency management
  • Dedicated bot users in both Redmine and Planka
  • Shared identity provider (OIDC) between Redmine and Planka (user matching by email)

Setup

Install dependencies

uv sync

Board setup

  1. Add the bot user as a member of the Planka board you want synced.
  2. Name your Planka lists to match Redmine status names (case-insensitive).
  3. Create cards with [#ID] prefix (e.g. [#1234] Fix login bug) for each ticket to sync.

Environment variables

Variable Description
REDMINE_URL Redmine instance URL
REDMINE_API_KEY API key for the Redmine bot user
REDMINE_BOT_USER_ID Numeric user ID of the Redmine bot
PLANKA_URL Planka instance URL
PLANKA_API_TOKEN API token for the Planka bot user
PLANKA_BOT_USER_ID User ID of the Planka bot
CRON_INTERVAL_MINUTES Polling interval in minutes (default: 5). Also determines the lookback window (interval * 2) for detecting recent changes.
PLANKA_USERS_FILE Path to the static user map JSON file (default: planka_users.json). See User mapping.
REDMINE_USERS_FILE Path to a JSON file mapping email → Redmine user ID (default: redmine_users.json). Used to resolve Redmine users without admin API access.
GERRIT_URL (optional) Gerrit instance base URL. Gerrit enrichment is disabled if unset.
GERRIT_USERNAME (optional) Gerrit HTTP username (required if GERRIT_URL is set).
GERRIT_HTTP_PASSWORD (optional) Gerrit HTTP password from Gerrit account settings (required if GERRIT_URL is set).
GERRIT_LABELS (optional) Comma-separated label names to show in the card description (default: Code-Review,Verified).

User mapping

Hermes matches Redmine users to Planka users by email. Two static mapping files are used:

  • planka_users.json (email → Planka user ID): required because the Planka API only exposes other users' emails to global admins.
  • redmine_users.json (email → Redmine user ID): used to resolve Redmine users without relying on the admin-only /users API. Built incrementally from ticket assignee/tester lookups during normal operation.

Both files are committed to the repo and baked into the Docker image.

Refreshing the Planka user map

When a new user joins Planka:

  1. Temporarily grant the bot global admin rights in the Planka admin UI.
  2. Run:
    uv run scripts/refresh_planka_users.py
  3. Revert the bot to its normal (non-admin) role.
  4. Commit the updated planka_users.json and rebuild/redeploy the image.

Running

Locally

uv run hermes

Docker

docker build -t hermes .
docker run --env-file .env hermes

Kubernetes

Deploy the CronJob and create the credentials secret:

kubectl create secret generic hermes-credentials \
  --from-literal=REDMINE_URL=https://redmine.example.com \
  --from-literal=REDMINE_API_KEY=... \
  --from-literal=REDMINE_BOT_USER_ID=... \
  --from-literal=PLANKA_URL=https://planka.example.com \
  --from-literal=PLANKA_API_TOKEN=... \
  --from-literal=PLANKA_BOT_USER_ID=...

kubectl apply -f k8s/cronjob.yaml

The CronJob runs every 5 minutes with concurrencyPolicy: Forbid (no overlapping runs), a 4-minute deadline, and no retries on failure.

Development

Run tests

uv run pytest

Lint and format

uv run ruff check src/ tests/
uv run ruff format src/ tests/

Project Structure

src/hermes/
├── __main__.py          # Entry point
├── config.py            # Settings from env vars
├── models.py            # Domain dataclasses (TicketInfo, CardInfo, GerritInfo, ChangeOrigin)
├── clients/
│   ├── planka.py        # Planka HTTP client (httpx)
│   ├── redmine.py       # Redmine API wrapper (python-redmine)
│   └── gerrit.py        # Gerrit REST client (httpx, read-only)
└── sync/
    ├── engine.py        # Main orchestrator loop
    ├── reconciler.py    # Per-pair reconciliation (3 cases)
    └── matching.py      # Card↔ticket matching via [#ID] prefix

planka_users.json        # Static email→Planka user ID map (see User mapping)
redmine_users.json       # Static email→Redmine user ID map (see User mapping)
scripts/
└── refresh_planka_users.py  # Refreshes planka_users.json (requires bot admin)

About

Sync Redmine, Planka and Gerrit

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors