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.
Each run:
- Discovers Planka boards where the bot user is a member
- For each board, scans for cards with
[#ID]prefix and fetches the corresponding Redmine tickets - If Gerrit is configured, fetches live label votes and reviewer emails for each ticket with a Gerrit Review URL (aggregated across the change series)
- 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
- Posts a warning comment on cards referencing invalid Redmine ticket IDs (deduplicated)
| 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.
- 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)
uv sync- Add the bot user as a member of the Planka board you want synced.
- Name your Planka lists to match Redmine status names (case-insensitive).
- Create cards with
[#ID]prefix (e.g.[#1234] Fix login bug) for each ticket to sync.
| 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). |
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/usersAPI. Built incrementally from ticket assignee/tester lookups during normal operation.
Both files are committed to the repo and baked into the Docker image.
When a new user joins Planka:
- Temporarily grant the bot global admin rights in the Planka admin UI.
- Run:
uv run scripts/refresh_planka_users.py
- Revert the bot to its normal (non-admin) role.
- Commit the updated
planka_users.jsonand rebuild/redeploy the image.
uv run hermesdocker build -t hermes .
docker run --env-file .env hermesDeploy 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.yamlThe CronJob runs every 5 minutes with concurrencyPolicy: Forbid (no overlapping runs), a 4-minute deadline, and no retries on failure.
uv run pytestuv run ruff check src/ tests/
uv run ruff format src/ tests/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)