Skip to content
12월 2, 2025chattiboyCloudNative, docker, kubernetes, PaaS

redmine | mattermost-redmine-bot

image 9

mattermost에서 redmine을 연동해서 사용하려고하다보니 구현이 필요함.

bot기능 구현 코드 github: https://github.com/icurfer/mattermost-redmine-bot

Redmine

RestAPI 기능 활성

REST web service를 활성한다.

image 1

API key 조회

image 2

기능 개발

import os
import sqlite3
from flask import Flask, request, Response, jsonify
import requests
from dotenv import load_dotenv
from cryptography.fernet import Fernet

# ---------------------------------------------------------------
# ENV Load
# ---------------------------------------------------------------
load_dotenv()

app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False

REDMINE_URL = os.getenv("REDMINE_URL")
MM_SLASH_TOKEN = os.getenv("MM_SLASH_TOKEN", "")

# ---------------------------------------------------------------
# Fernet Key 준비
# ---------------------------------------------------------------
FERNET_KEY = os.getenv("FERNET_KEY")
if not FERNET_KEY:
    # FERNET_KEY = Fernet.generate_key().decode()
    # print("⚠️ FERNET_KEY generated! Store this in your .env:", FERNET_KEY)
    raise Exception("FERNET_SECRET_KEY is missing! Set it in .env")

fernet = Fernet(FERNET_KEY.encode())

# ---------------------------------------------------------------
# SQLite DB 준비
# ---------------------------------------------------------------
DB_DIR = './db'
os.makedirs(DB_DIR, exist_ok=True)
DB_PATH = f"{DB_DIR}/sqlite3.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS user_keys (
            username TEXT PRIMARY KEY,
            redmine_id TEXT,
            api_key TEXT
        )
    """)
    conn.commit()
    conn.close()

init_db()


def save_user_login(username, redmine_id, raw_api_key):
    encrypted_api_key = fernet.encrypt(raw_api_key.encode()).decode()

    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("""
        INSERT INTO user_keys (username, redmine_id, api_key)
        VALUES (?,?,?)
        ON CONFLICT(username) DO UPDATE SET
            redmine_id=excluded.redmine_id,
            api_key=excluded.api_key
    """, (username, redmine_id, encrypted_api_key))
    conn.commit()
    conn.close()


def load_user_login(username):
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("SELECT redmine_id, api_key FROM user_keys WHERE username=?", (username,))
    row = cur.fetchone()
    conn.close()

    if not row:
        return None, None

    decrypted_key = fernet.decrypt(row[1].encode()).decode()
    return row[0], decrypted_key


def delete_user_login(username):
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("DELETE FROM user_keys WHERE username=?", (username,))
    conn.commit()
    conn.close()


def text_response(text):
    return Response(text, mimetype="text/plain; charset=utf-8")


def help_message():
    return text_response(
        "📌 사용법:\n"
        "/redmine login <id> <api_key>    → Redmine 로그인\n"
        "/redmine logout                  → 로그아웃\n"
        "/redmine me                      → 내 계정 정보\n"
        "/redmine list [project]          → 이슈 목록\n"
        "/redmine issue <id>              → 이슈 상세조회\n"
        "/redmine close <id>              → 이슈 닫기\n"
        "/redmine create <project> <제목> → 이슈 생성\n"
        "/redmine projects                → 프로젝트 목록\n"
    )

# ---------------------------------------------------------------
# Slash Command
# ---------------------------------------------------------------
@app.post("/slash/redmine")
def slash_redmine():
    mm_token = request.form.get("token", "")
    mm_user = request.form.get("user_name", "")
    text = request.form.get("text", "").strip()

    if MM_SLASH_TOKEN and mm_token != MM_SLASH_TOKEN:
        return text_response("Invalid token")

    if not text:
        return help_message()

    parts = text.split(" ", 1)
    command = parts[0]

    # -----------------------------------------------------------
    # LOGIN
    # -----------------------------------------------------------
    if command == "login":
        if len(parts) == 1:
            return text_response("사용법: /redmine login <redmine_id> <api_key>")

        sub = parts[1].split(" ", 1)
        if len(sub) != 2:
            return text_response("사용법: /redmine login <redmine_id> <api_key>")

        redmine_id = sub[0]
        api_key = sub[1]

        # -----------------------------
        # 🔐 API KEY 유효성 검증
        # -----------------------------
        verify_res = requests.get(
            f"{REDMINE_URL}/users/current.json",
            params={"key": api_key}
        )

        if not verify_res.ok:
            return text_response("❌ 로그인 실패: API Key가 올바르지 않습니다.")

        # 로그인 성공이므로 DB 저장
        save_user_login(mm_user, redmine_id, api_key)
        return text_response(
            f"🔑 로그인 완료!\n"
            f"- Mattermost 사용자: {mm_user}\n"
            f"- Redmine ID: {redmine_id}"
        )


    # -----------------------------------------------------------
    # LOGOUT
    # -----------------------------------------------------------
    if command == "logout":
        redmine_id, user_key = load_user_login(mm_user)
        if not user_key:
            return text_response("❌ 저장된 로그인 정보 없음")

        delete_user_login(mm_user)
        return text_response("🔓 로그아웃 완료!")

    # -----------------------------------------------------------
    # 로그인 여부 확인
    # -----------------------------------------------------------
    redmine_id, user_key = load_user_login(mm_user)
    if not user_key:
        return text_response(
            "❌ 로그인 필요\n"
            "/redmine login <redmine_id> <api_key>"
        )

    # -----------------------------------------------------------
    # /redmine me — 사용자 정보
    # -----------------------------------------------------------
    if command == "me":
        url = f"{REDMINE_URL}/users/current.json"
        res = requests.get(url, params={"key": user_key})

        if not res.ok:
            return text_response("사용자 정보 조회 실패")

        user = res.json().get("user", {})
        txt = (
            f"👤 Redmine 사용자 정보\n"
            f"- ID: {user.get('id')}\n"
            f"- Login: {user.get('login')}\n"
            f"- Name: {user.get('firstname')} {user.get('lastname')}\n"
            f"- Email: {user.get('mail')}"
        )
        return text_response(txt)

    # -----------------------------------------------------------
    # LIST
    # -----------------------------------------------------------
    if command == "list":
        project = parts[1].strip() if len(parts) > 1 else None

        params = {"key": user_key, "include": "project"}
        if project:
            params["project_id"] = project

        res = requests.get(f"{REDMINE_URL}/issues.json", params=params)
        if not res.ok:
            return text_response("이슈 조회 실패")

        issues = res.json().get("issues", [])
        if not issues:
            return text_response("이슈가 없습니다.")

        lines = []
        for i in issues:
            proj = i.get("project", {}).get("identifier") or i.get("project", {}).get("name", "unknown")
            lines.append(f"- [{proj}] #{i['id']} {i['subject']}")

        return text_response("\n".join(lines))

    # -----------------------------------------------------------
    # ISSUE — 상세조회
    # -----------------------------------------------------------
    if command == "issue":
        if len(parts) == 1:
            return text_response("사용법: /redmine issue <id>")

        issue_id = parts[1].strip()

        url = f"{REDMINE_URL}/issues/{issue_id}.json"
        res = requests.get(url, params={"key": user_key})

        if not res.ok:
            return text_response("이슈 조회 실패")

        issue = res.json()["issue"]
        txt = (
            f"📝 이슈 상세 정보\n"
            f"- ID: {issue['id']}\n"
            f"- 프로젝트: {issue['project']['name']}\n"
            f"- 제목: {issue['subject']}\n"
            f"- 상태: {issue['status']['name']}\n"
            f"- 담당자: {issue.get('assigned_to', {}).get('name','-')}\n"
            f"- 설명: {issue.get('description','(없음)')}"
        )
        return text_response(txt)

    # -----------------------------------------------------------
    # CLOSE — 이슈 닫기
    # -----------------------------------------------------------
    if command == "close":
        if len(parts) == 1:
            return text_response("사용법: /redmine close <id>")

        issue_id = parts[1].strip()
        payload = {"issue": {"status_id": 5}}   # 5=Closed

        url = f"{REDMINE_URL}/issues/{issue_id}.json"
        res = requests.put(url, params={"key": user_key}, json=payload)

        if not res.ok:
            return text_response("이슈 상태 변경 실패")

        return text_response(f"✅ 이슈 #{issue_id} Closed!")

    # -----------------------------------------------------------
    # CREATE
    # -----------------------------------------------------------
    if command == "create":
        if len(parts) == 1:
            return text_response("사용법: /redmine create <project> <제목>")

        sub = parts[1].split(" ", 1)
        if len(sub) < 2:
            return text_response("프로젝트와 제목 모두 필요")

        project = sub[0]
        subject = sub[1]

        payload = {"issue": {"project_id": project, "subject": subject}}

        res = requests.post(
            f"{REDMINE_URL}/issues.json",
            params={"key": user_key},
            json=payload
        )
        if not res.ok:
            return text_response("이슈 생성 실패")

        return text_response(f"🆕 [{project}] 이슈 생성됨: {subject}")

    # -----------------------------------------------------------
    # PROJECTS
    # -----------------------------------------------------------
    if command == "projects":
        res = requests.get(f"{REDMINE_URL}/projects.json", params={"key": user_key})
        if not res.ok:
            return text_response("프로젝트 조회 실패")

        projects = res.json().get("projects", [])
        txt = "\n".join(f"- {p['identifier']} : {p['name']}" for p in projects)
        return text_response(txt)

    # -----------------------------------------------------------
    return help_message()

# ---------------------------------------------------------------
# HEALTH CHECK
# ---------------------------------------------------------------
@app.get("/health")
def health():
    return jsonify({"status": "ok"})

# ---------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------
if __name__ == "__main__":
    port = int(os.getenv("PORT", "5000"))
    app.run(host="0.0.0.0", port=port)

Mattermost

mattermost 연동

slash commands를 선택

image 3

Add Slash Command

image 4
Title: Redmine Bot
Description: Redmine Integration via Flask Slash Command
Command Trigger Word: redmine
Request URL: ${Host url}
Request Method: Post
Response Username: redmine-bot
image 5
image 8

save를누르면 Token이 발행된다.
이 토큰을 flask의 환경변수에 추가한다.

image 6

내부 네트워크를 허용하도록 설정

image 7

Docker build

# pull official base image
FROM python:3.10-slim-bullseye

# create app user
RUN useradd -m appuser

# set work directory
WORKDIR /usr/src/app

# make directory writable for appuser
RUN mkdir -p /usr/src/app && chown -R appuser:appuser /usr/src/app

# environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# copy project files
COPY . /usr/src/app/

# install python dependencies (still as root → safer)
USER root
RUN pip install --upgrade pip && pip install -r requirements.txt

# change file owner to appuser after installation
RUN chown -R appuser:appuser /usr/src/app

RUN mkdir -p /usr/src/app/src/db \
    && chown -R appuser:appuser /usr/src/app/src/db


# switch to non-root user
USER appuser

# expose port
EXPOSE 5000

# run gunicorn as non-root user
CMD ["gunicorn", "--workers=3", "--bind=0.0.0.0:5000", "src.app:app"]

Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: mm-rdm-bot
  labels:
    app: mm-rdm-bot
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mm-rdm-bot
  template:
    metadata:
      labels:
        app: mm-rdm-bot
    spec:
      containers:
        - name: mm-rdm-bot
          image: icurfer/mattermost-redmine-bot:0.1
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 5000
          env:
            - name: REDMINE_URL
              value: ${redmine_url}
            - name: MM_SLASH_TOKEN
              value: ${mattermost_token}
            - name: FERNET_KEY
              value: ${created_secret}
---
apiVersion: v1
kind: Service
metadata:
  name: mm-rdm-bot
  labels:
    app: mm-rdm-bot
spec:
  type: ClusterIP
  selector:
    app: mm-rdm-bot
  ports:
    - name: http
      port: 5000
      targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mm-rdm-bot
spec:
  ingressClassName: nginx
  rules:
  - host: mm-rdm-bot.example.com
    http:
      paths:
      - backend:
          service:
            name: mm-rdm-bot
            port:
              number: 5000
        path: /
        pathType: Prefix

mattermost 수정

Integrations > Slash Commands에서 endpoint를 kubernetes ingress쪽으로 수정.

기본기능은 잘 동작한다. 필요에 따라 추가 기능 구현 하면 될것 같다.

image 9