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:
2026-09-02 13:55:46 +09:00
co-authored by Claude Opus 5
parent d23bcabd76
commit a458509014
7 changed files with 1021 additions and 5 deletions
+446
View File
@@ -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}")