Redmine へチケットを起票するボタンを追加
chktime のタスクと git 履歴から下書きを作り、まとめて Redmine へ登録できる ようにした。外部通信はこの機能を使ったときだけで、使わなければ従来どおり ローカルの jsonl を読むだけ。 - Redmine の REST API を叩く最小クライアント (標準ライブラリのみ) - 下書きは「タスクから」(1タスク=1チケット) と「git 履歴から」 (日ごと / 日 x 種別 / 期間一括) の 2 系統 - 起票は件数 x 間隔ぶん時間がかかるのでジョブにし、進捗だけ見に行く - 作成時の status_id は Redmine 側で無視されるため、作成後に付け直す - 連続アクセスに 403 を返すサーバがあるので 1 件ごとに間隔をあける (既定 7 秒) - 自己署名証明書が普通なので、既定は証明書の検証なし - API キーは redmine.json に置き、git 管理外にする Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+446
@@ -22,8 +22,12 @@ import os
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -797,6 +801,396 @@ def decode_project_dir(name):
|
||||
return name.replace("-", "/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Redmine 連携
|
||||
|
||||
REDMINE_CONFIG = APP_DIR / "redmine.json"
|
||||
REDMINE_INTERVAL = 7.0 # 連続で叩くと 403 を返すサーバがあるので、呼び出しの間隔をあける
|
||||
TURN_LIMIT = 60 # チケット本文に載せるやりとりの上限
|
||||
COMMIT_LIMIT = 80 # チケット本文に載せるコミットの上限
|
||||
GIT_SEP = "\x1f"
|
||||
GIT_FMT = GIT_SEP.join(["%H", "%h", "%ad", "%an", "%s"])
|
||||
COMMIT_TYPE_RE = re.compile(r"^(\w+)(?:\([^)]*\))?!?:\s")
|
||||
TYPE_LABEL = {
|
||||
"feat": "機能追加", "fix": "不具合修正", "docs": "書類", "test": "テスト",
|
||||
"perf": "高速化", "refactor": "整理", "build": "ビルド", "chore": "雑務",
|
||||
"release": "リリース", "style": "整形", "ci": "CI", "legal": "法務", "wip": "作業中",
|
||||
}
|
||||
|
||||
|
||||
class RedmineError(Exception):
|
||||
"""Redmine 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
|
||||
|
||||
|
||||
def fmt_local(epoch, time_only=False):
|
||||
if epoch is None:
|
||||
return "–"
|
||||
stamp = datetime.fromtimestamp(epoch)
|
||||
return stamp.strftime("%H:%M:%S") if time_only else stamp.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def local_date(epoch):
|
||||
return datetime.fromtimestamp(epoch).date().isoformat() if epoch else None
|
||||
|
||||
|
||||
class Redmine:
|
||||
"""Redmine の REST API を叩く最小のクライアント (標準ライブラリのみ)。
|
||||
|
||||
社内 Redmine は自己署名証明書のことが多いので、verify=False なら検証を外す。
|
||||
また連続アクセスに 403 を返す設定のサーバがあるため、呼び出しの間隔をあける。
|
||||
"""
|
||||
|
||||
def __init__(self, url=None, key=None, verify=False, interval=REDMINE_INTERVAL):
|
||||
self.url = (url or "").rstrip("/")
|
||||
self.key = key or ""
|
||||
self.interval = max(0.0, float(interval or 0))
|
||||
self.ctx = None
|
||||
if not verify:
|
||||
self.ctx = ssl.create_default_context()
|
||||
self.ctx.check_hostname = False
|
||||
self.ctx.verify_mode = ssl.CERT_NONE
|
||||
self._last = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def throttle(self):
|
||||
"""前回の呼び出しから interval 秒あくまで待つ。"""
|
||||
with self._lock:
|
||||
gap = self.interval - (time.monotonic() - self._last)
|
||||
if gap > 0:
|
||||
time.sleep(gap)
|
||||
self._last = time.monotonic()
|
||||
|
||||
def call(self, path, method="GET", payload=None):
|
||||
if not self.url:
|
||||
raise RedmineError("Redmine の URL が未設定です")
|
||||
if not self.key:
|
||||
raise RedmineError("API アクセスキーが未設定です")
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(self.url + path, data=body, method=method)
|
||||
req.add_header("X-Redmine-API-Key", self.key)
|
||||
req.add_header("Accept", "application/json")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
self.throttle()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as res:
|
||||
raw = res.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
|
||||
raise RedmineError(f"HTTP {exc.code}: {detail or exc.reason}") from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise RedmineError(f"接続できません: {exc.reason}") from None
|
||||
except OSError as exc:
|
||||
raise RedmineError(f"接続できません: {exc}") from None
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
raise RedmineError("JSON として読めない応答が返りました") from None
|
||||
|
||||
def projects(self):
|
||||
out = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = self.call(f"/projects.json?limit=100&offset={offset}")
|
||||
items = data.get("projects") or []
|
||||
out += [{"id": p.get("id"), "identifier": p.get("identifier"), "name": p.get("name")} for p in items]
|
||||
offset += len(items)
|
||||
if not items or offset >= (data.get("total_count") or 0):
|
||||
return out
|
||||
|
||||
def enums(self):
|
||||
"""トラッカー / ステータス / 自分の情報。起票フォームの選択肢に使う。"""
|
||||
trackers = self.call("/trackers.json").get("trackers") or []
|
||||
statuses = self.call("/issue_statuses.json").get("issue_statuses") or []
|
||||
me = (self.call("/users/current.json").get("user")) or {}
|
||||
return {
|
||||
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers],
|
||||
"statuses": [{"id": s.get("id"), "name": s.get("name"), "closed": bool(s.get("is_closed"))} for s in statuses],
|
||||
"user": {"id": me.get("id"), "login": me.get("login"),
|
||||
"name": " ".join(filter(None, [me.get("lastname"), me.get("firstname")])).strip()},
|
||||
}
|
||||
|
||||
def create(self, fields):
|
||||
"""チケットを作る。
|
||||
|
||||
Redmine は作成時の status_id を既定値で上書きするので、指定があれば
|
||||
作成後に PUT で付け直す。
|
||||
"""
|
||||
fields = dict(fields)
|
||||
status = fields.pop("status_id", None)
|
||||
data = self.call("/issues.json", "POST", {"issue": fields})
|
||||
issue_id = (data.get("issue") or {}).get("id")
|
||||
if issue_id and status:
|
||||
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
|
||||
return issue_id
|
||||
|
||||
|
||||
def load_redmine_config(path):
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
class RedmineHolder:
|
||||
"""設定ファイルと、そこから作ったクライアントを保持する。
|
||||
|
||||
呼び出し間隔の記憶をなくさないよう、設定が変わったときだけ作り直す。
|
||||
API キーは UI へ返さない (有無だけ返す)。
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
self.lock = threading.Lock()
|
||||
self.cfg = load_redmine_config(self.path)
|
||||
self.client = self._build()
|
||||
|
||||
def _build(self):
|
||||
return Redmine(self.cfg.get("url"), self.cfg.get("key"),
|
||||
self.cfg.get("verifySsl", False), self.cfg.get("interval", REDMINE_INTERVAL))
|
||||
|
||||
def public(self):
|
||||
with self.lock:
|
||||
cfg = self.cfg
|
||||
return {
|
||||
"url": cfg.get("url", ""),
|
||||
"hasKey": bool(cfg.get("key")),
|
||||
"verifySsl": bool(cfg.get("verifySsl", False)),
|
||||
"interval": cfg.get("interval", REDMINE_INTERVAL),
|
||||
"projectId": cfg.get("projectId"),
|
||||
"trackerId": cfg.get("trackerId"),
|
||||
"statusId": cfg.get("statusId"),
|
||||
"assignedToId": cfg.get("assignedToId"),
|
||||
"configPath": str(self.path),
|
||||
}
|
||||
|
||||
def update(self, patch):
|
||||
"""URL・キー・既定値を保存する。key が空文字なら既存のキーを残す。"""
|
||||
with self.lock:
|
||||
cfg = dict(self.cfg)
|
||||
for key in ("url", "verifySsl", "interval", "projectId", "trackerId", "statusId", "assignedToId"):
|
||||
if key in patch:
|
||||
cfg[key] = patch[key]
|
||||
if patch.get("clearKey"):
|
||||
cfg.pop("key", None)
|
||||
elif patch.get("key"):
|
||||
cfg["key"] = str(patch["key"]).strip()
|
||||
self.cfg = cfg
|
||||
self.client = self._build()
|
||||
try:
|
||||
self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RedmineError(f"設定を保存できません: {exc}") from None
|
||||
return self.public()
|
||||
|
||||
|
||||
class Jobs:
|
||||
"""起票をバックグラウンドで流し、UI からは進捗だけ見えるようにする。
|
||||
|
||||
1件ごとに間隔をあけるので数十件で数分かかる。HTTP を掴んだままにしないため、
|
||||
開始だけ返して /api/redmine/job で進捗を取りに来てもらう。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._jobs = {}
|
||||
self._lock = threading.Lock()
|
||||
self._seq = 0
|
||||
|
||||
def start(self, client, drafts):
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
job = {"id": f"j{self._seq}", "total": len(drafts), "done": 0, "state": "running", "results": []}
|
||||
self._jobs[job["id"]] = job
|
||||
threading.Thread(target=self._run, args=(job, client, drafts), daemon=True).start()
|
||||
return job
|
||||
|
||||
def _run(self, job, client, drafts):
|
||||
for draft in drafts:
|
||||
subject = draft.get("subject") or "(件名なし)"
|
||||
try:
|
||||
issue_id = client.create(draft)
|
||||
job["results"].append({"subject": subject, "issueId": issue_id, "ok": True})
|
||||
except RedmineError as exc:
|
||||
job["results"].append({"subject": subject, "error": str(exc), "ok": False})
|
||||
job["done"] += 1
|
||||
job["state"] = "done"
|
||||
|
||||
def get(self, job_id):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
raise FileNotFoundError(job_id)
|
||||
return job
|
||||
|
||||
|
||||
# -- 下書きの組み立て -------------------------------------------------
|
||||
|
||||
|
||||
def issue_from_session(session, project_path=None):
|
||||
"""chktime のタスク 1 件をチケットの下書きにする。"""
|
||||
start, end = session.get("startEpoch"), session.get("endEpoch")
|
||||
head = [
|
||||
f"* 期間: {fmt_local(start)} → {fmt_local(end)}",
|
||||
f"* 全体: {fmt_dur(session['wallMs'])} / 作業時間: {fmt_dur(session['activeMs'])}",
|
||||
f"* 往復: {session['turnCount']} · 応答メッセージ: {session['assistantMsgs']} · ツール呼び出し: {session['toolCalls']}",
|
||||
]
|
||||
if project_path:
|
||||
head.append(f"* 作業ディレクトリ: {project_path}")
|
||||
if session.get("gitBranch"):
|
||||
head.append(f"* ブランチ: {session['gitBranch']}")
|
||||
|
||||
turns = [t for t in session.get("turns", []) if t.get("kind") in ("prompt", "command")]
|
||||
rows = ["h3. やりとり", ""]
|
||||
for turn in turns[:TURN_LIMIT]:
|
||||
rows.append(f"* {fmt_local(turn['ts'], True)} #{turn['i']} "
|
||||
f"{one_line(turn['prompt'], 120)} — {fmt_dur(turn['durMs'])}")
|
||||
if len(turns) > TURN_LIMIT:
|
||||
rows.append(f"* … 他 {len(turns) - TURN_LIMIT} 件")
|
||||
if not turns:
|
||||
rows.append("* (記録なし)")
|
||||
|
||||
return {
|
||||
"subject": one_line(session.get("title") or session["id"], 240),
|
||||
"description": "\n".join(head + [""] + rows + ["", "---", f"chktime から起票(タスク {session['id']})"]),
|
||||
"start_date": local_date(start),
|
||||
"due_date": local_date(end),
|
||||
"source": {"kind": "task", "id": session["id"]},
|
||||
}
|
||||
|
||||
|
||||
def git_log(repo, since=None, until=None, limit=2000):
|
||||
"""git 履歴を読む。リポジトリでなければ RedmineError。"""
|
||||
path = Path(repo).expanduser()
|
||||
if not path.is_dir():
|
||||
raise RedmineError(f"フォルダが見つかりません: {repo}")
|
||||
cmd = ["git", "-C", str(path), "log", "--no-merges", "--date=short", f"--pretty=format:{GIT_FMT}", f"-n{limit}"]
|
||||
if since:
|
||||
cmd.append(f"--since={since} 00:00:00")
|
||||
if until:
|
||||
cmd.append(f"--until={until} 23:59:59")
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=60)
|
||||
except FileNotFoundError:
|
||||
raise RedmineError("git が見つかりません (PATH を確認してください)") from None
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise RedmineError(f"git を実行できません: {exc}") from None
|
||||
if proc.returncode:
|
||||
detail = one_line(proc.stderr.decode("utf-8", "replace"), 200)
|
||||
raise RedmineError(detail or f"git log に失敗しました: {path}")
|
||||
out = []
|
||||
for line in proc.stdout.decode("utf-8", "replace").splitlines():
|
||||
parts = line.split(GIT_SEP)
|
||||
if len(parts) == 5:
|
||||
out.append({"hash": parts[0], "short": parts[1], "date": parts[2], "author": parts[3], "subject": parts[4]})
|
||||
out.reverse() # 古い順
|
||||
return out
|
||||
|
||||
|
||||
def commit_type(subject):
|
||||
match = COMMIT_TYPE_RE.match(subject or "")
|
||||
return match.group(1).lower() if match else ""
|
||||
|
||||
|
||||
def group_commits(commits, mode):
|
||||
"""コミットを起票の単位にまとめる。mode: day=日ごと / type=日×種別 / all=一括。
|
||||
|
||||
分類は機械的に行う (件名の prefix と日付だけを見る)。
|
||||
"""
|
||||
groups = {}
|
||||
order = []
|
||||
for commit in commits:
|
||||
if mode == "all":
|
||||
key = ("all",)
|
||||
elif mode == "type":
|
||||
key = (commit["date"], commit_type(commit["subject"]))
|
||||
else:
|
||||
key = (commit["date"],)
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
order.append(key)
|
||||
groups[key].append(commit)
|
||||
|
||||
out = []
|
||||
for key in order:
|
||||
items = groups[key]
|
||||
dates = [c["date"] for c in items]
|
||||
start, due = min(dates), max(dates)
|
||||
if mode == "all":
|
||||
span = start if start == due else f"{start} 〜 {due}"
|
||||
subject = f"{span} の作業({len(items)} コミット)"
|
||||
elif mode == "type":
|
||||
kind = key[1]
|
||||
label = TYPE_LABEL.get(kind, kind or "その他")
|
||||
subject = f"{key[0]} {label}({len(items)} コミット)"
|
||||
else:
|
||||
subject = f"{key[0]} の作業({len(items)} コミット)"
|
||||
out.append({"subject": subject, "start": start, "due": due, "commits": items})
|
||||
return out
|
||||
|
||||
|
||||
def issue_from_commits(group, repo):
|
||||
"""コミットのまとまり 1 つをチケットの下書きにする。"""
|
||||
items = group["commits"]
|
||||
rows = ["h3. 対応したコミット", ""]
|
||||
for commit in items[:COMMIT_LIMIT]:
|
||||
rows.append(f"* {commit['date']} {commit['short']} {commit['subject']}")
|
||||
if len(items) > COMMIT_LIMIT:
|
||||
rows.append(f"* … 他 {len(items) - COMMIT_LIMIT} 件")
|
||||
return {
|
||||
"subject": one_line(group["subject"], 240),
|
||||
"description": "\n".join(rows + ["", "---", f"git 履歴から起票(リポジトリ: {repo})"]),
|
||||
"start_date": group["start"],
|
||||
"due_date": group["due"],
|
||||
"source": {"kind": "git", "id": items[0]["short"], "count": len(items)},
|
||||
}
|
||||
|
||||
|
||||
def build_drafts(store, payload):
|
||||
"""UI から来た指定で下書きの一覧を作る。source: tasks / git。"""
|
||||
source = payload.get("source")
|
||||
if source == "tasks":
|
||||
project = payload.get("project") or ""
|
||||
ids = payload.get("ids") or []
|
||||
if not ids:
|
||||
raise RedmineError("タスクが選ばれていません")
|
||||
paths = {p["id"]: p["path"] for p in store.projects()}
|
||||
drafts = []
|
||||
for item in ids:
|
||||
pid, sid = (item.get("project") or project, item.get("id")) if isinstance(item, dict) else (project, item)
|
||||
session = store.one(pid, sid)
|
||||
drafts.append(issue_from_session(session, session.get("cwd") or paths.get(pid)))
|
||||
return drafts
|
||||
if source == "git":
|
||||
repo = payload.get("repo") or ""
|
||||
commits = git_log(repo, payload.get("from"), payload.get("to"))
|
||||
if not commits:
|
||||
raise RedmineError("その期間のコミットがありません")
|
||||
return [issue_from_commits(g, repo) for g in group_commits(commits, payload.get("mode") or "day")]
|
||||
raise RedmineError(f"不明な取得元です: {source}")
|
||||
|
||||
|
||||
def to_fields(draft, defaults):
|
||||
"""下書き + 既定値を Redmine の issue フィールドにする。"""
|
||||
fields = {
|
||||
"project_id": defaults.get("projectId"),
|
||||
"subject": draft.get("subject") or "(件名なし)",
|
||||
"description": draft.get("description") or "",
|
||||
}
|
||||
if not fields["project_id"]:
|
||||
raise RedmineError("起票先のプロジェクトが選ばれていません")
|
||||
for key, name in (("trackerId", "tracker_id"), ("statusId", "status_id"), ("assignedToId", "assigned_to_id")):
|
||||
if defaults.get(key):
|
||||
fields[name] = defaults[key]
|
||||
for key in ("start_date", "due_date"):
|
||||
if draft.get(key):
|
||||
fields[key] = draft[key]
|
||||
if defaults.get("doneRatio") is not None:
|
||||
fields["done_ratio"] = defaults["doneRatio"]
|
||||
return fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- HTTP
|
||||
|
||||
|
||||
@@ -912,9 +1306,49 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.static(route)
|
||||
except FileNotFoundError as exc:
|
||||
self.send_json({"error": f"not found: {exc}"}, 404)
|
||||
except RedmineError as exc:
|
||||
self.send_json({"error": str(exc)}, 400)
|
||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
route = urlparse(self.path).path
|
||||
try:
|
||||
if route.startswith("/api/"):
|
||||
return self.api_post(route, self.read_json())
|
||||
raise FileNotFoundError(route)
|
||||
except FileNotFoundError as exc:
|
||||
self.send_json({"error": f"not found: {exc}"}, 404)
|
||||
except (RedmineError, ValueError) as exc:
|
||||
self.send_json({"error": str(exc)}, 400)
|
||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||
|
||||
def read_json(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if not length:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length))
|
||||
except ValueError:
|
||||
raise ValueError("JSON として読めません") from None
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def api_post(self, route, payload):
|
||||
redmine = self.server.redmine
|
||||
if route == "/api/redmine/config":
|
||||
return self.send_json(redmine.update(payload))
|
||||
if route == "/api/redmine/draft":
|
||||
return self.send_json({"drafts": build_drafts(self.server.store, payload)})
|
||||
if route == "/api/redmine/issues":
|
||||
drafts = payload.get("drafts") or []
|
||||
if not drafts:
|
||||
raise RedmineError("起票するものがありません")
|
||||
defaults = payload.get("defaults") or {}
|
||||
fields = [to_fields(d, defaults) for d in drafts]
|
||||
return self.send_json(self.server.jobs.start(redmine.client, fields))
|
||||
raise FileNotFoundError(route)
|
||||
|
||||
def api(self, route, query):
|
||||
store = self.server.store
|
||||
if route == "/api/projects":
|
||||
@@ -955,6 +1389,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if query.get("format", [""])[0] == "csv":
|
||||
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
|
||||
return self.send_json(session)
|
||||
if route == "/api/redmine/config":
|
||||
return self.send_json(self.server.redmine.public())
|
||||
if route == "/api/redmine/meta":
|
||||
client = self.server.redmine.client
|
||||
data = client.enums()
|
||||
data["projects"] = client.projects()
|
||||
return self.send_json(data)
|
||||
if route == "/api/redmine/job":
|
||||
return self.send_json(self.server.jobs.get(query.get("id", [""])[0]))
|
||||
if route == "/api/reload":
|
||||
store.clear()
|
||||
return self.send_json({"ok": True})
|
||||
@@ -1046,6 +1489,7 @@ def main():
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
|
||||
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
|
||||
parser.add_argument("--redmine-config", default=str(REDMINE_CONFIG), help="Redmine 連携の設定ファイル")
|
||||
parser.add_argument("--no-browser", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1065,6 +1509,8 @@ def main():
|
||||
webbrowser.open(url)
|
||||
return
|
||||
server.store = Store(root)
|
||||
server.redmine = RedmineHolder(args.redmine_config)
|
||||
server.jobs = Jobs()
|
||||
server.watcher = Watcher(root, args.interval)
|
||||
server.watcher.start()
|
||||
print(f"chktime: {root}")
|
||||
|
||||
Reference in New Issue
Block a user