From a4585090145a0e4444ff0077b522fc097d438a41 Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Wed, 2 Sep 2026 13:55:46 +0900 Subject: [PATCH] =?UTF-8?q?Redmine=20=E3=81=B8=E3=83=81=E3=82=B1=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=B5=B7=E7=A5=A8=E3=81=99=E3=82=8B=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + CLAUDE.md | 35 +++- README.md | 41 ++++- chktime.py | 446 +++++++++++++++++++++++++++++++++++++++++++++++++ web/app.js | 391 +++++++++++++++++++++++++++++++++++++++++++ web/index.html | 18 ++ web/style.css | 92 ++++++++++ 7 files changed, 1021 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 6fdd889..ac05e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ # Claude Code のローカル設定 .claude/settings.local.json + +# Redmine 連携の設定 (API アクセスキーが入る) +redmine.json diff --git a/CLAUDE.md b/CLAUDE.md index c273036..e51da96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,20 +6,25 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ ## 構成 ``` -chktime.py サーバ + jsonl 解析(これ1本で完結) +chktime.py サーバ + jsonl 解析 + Redmine 連携(これ1本で完結) web/index.html 画面の骨組み web/app.js 表示ロジック(フレームワークなし・素の DOM 操作) web/style.css スタイル(ダークテーマ固定) run.bat コンソール表示で起動 stop.cmd 起動中の chktime を終了 +redmine.json Redmine 連携の設定(自動生成・git 管理外) ``` -`chktime.py` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。 +`chktime.py` の中身は上から +「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ Redmine 連携 → HTTP」の順。 ## 守ること - **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。 -- **外部通信しない**。読むのはローカルの jsonl だけ。 +- **外部通信は Redmine 連携だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。 + 新しい通信先を増やすときは、この行を書き換えるところから始める。 +- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない + (`/api/redmine/config` はキーの有無だけ返す)。 - 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。 - `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。 - コメント・コミットメッセージ・UI の文言は日本語。 @@ -50,6 +55,16 @@ python chktime.py --port 8799 --no-browser いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。 +Redmine 連携(POST を使う唯一の系統): + +| ルート | 内容 | +| --- | --- | +| `GET/POST /api/redmine/config` | 接続設定の取得(キーは返さない)/ 保存 | +| `GET /api/redmine/meta` | プロジェクト・トラッカー・ステータス・自分の情報 | +| `POST /api/redmine/draft` | 下書きを作る(`source=tasks` / `source=git`) | +| `POST /api/redmine/issues` | 起票をジョブとして開始。返り値の `id` で進捗を見る | +| `GET /api/redmine/job?id=` | 起票ジョブの進捗と結果 | + ## 解析の前提 - ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。 @@ -70,3 +85,17 @@ python chktime.py --port 8799 --no-browser ターン単位で共有している。 - `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、 `clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。 +- Redmine 起票のダイアログは `rm` オブジェクトが状態を持ち、`renderRedmine()` が中身を作り直す。 + HTML 属性に NUL は入れられない(パーサが置換する)ので、タスクの識別は `data-project` / + `data-id` の 2 属性で持つ。 + +## Redmine 連携の落とし穴 + +実際に踏んだもの。同じところで転ばないように。 + +- **作成時の `status_id` は無視される**(既定の New で入る)。状態を指定したいなら作成後に + `PUT /issues/.json` で付け直す(`Redmine.create()` がやっている)。 +- **連続アクセスに 403 を返すサーバがある**。7〜8 秒あければ通る。`Redmine.throttle()` が + クライアント単位で間隔を保つので、クライアントは作り直さず使い回す。 +- **自己署名証明書**が普通なので、既定は検証なし(`verifySsl: false`)。 +- 起票は件数×間隔ぶん時間がかかるので HTTP を掴んだままにしない。`Jobs` に投げて進捗を取りに来てもらう。 diff --git a/README.md b/README.md index 62d95ad..69d6fa6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ Claude Code の **タスク(セッション)ごとのチャットのやり `~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、 「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。 -外部通信なし・追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。 +追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。 + +外部への通信は **[Redmine へ起票](#redmine-へ起票)** を使ったときだけです。それ以外は +ローカルの jsonl を読むだけで、どこにも接続しません。 ## 起動 @@ -30,6 +33,7 @@ python chktime.py | `--host` | 127.0.0.1 | 待ち受けアドレス | | `--dir` | `~/.claude/projects` | ログの置き場所 | | `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 | +| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル | | `--no-browser` | – | ブラウザを自動で開かない | ## 画面 @@ -105,6 +109,38 @@ python chktime.py - オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。 - オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。 +## Redmine へ起票 + +ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。 +**このアプリで唯一、外部へ通信する機能**です。使わなければ一切接続しません。 + +### 準備 + +ダイアログ右上の **設定** で、Redmine の URL と API アクセスキーを入れて保存します。 +キーは Redmine の「個人設定 → API アクセスキー」で表示できます。 + +- 設定は `redmine.json`(`--redmine-config` で変更可)に保存されます。**キーが平文で入るので git 管理外**です。 +- 社内 Redmine のような自己署名証明書のサーバでは「証明書を検証する」を**外して**ください。 +- **間隔(秒)** は 1 件ごとに空ける待ち時間です。連続アクセスに 403 を返すサーバがあるため既定 7 秒。速いサーバなら短くできます。 + +### 起票する + +**下書きを作る → 中身を確かめる → 起票する** の 3 段です。取得元は 2 つあります。 + +| 取得元 | 内容 | +| --- | --- | +| **タスクから** | 中央の一覧に出ているタスクから選び、1 タスク = 1 チケットにします。本文は期間・全体/作業時間・往復数・ツール呼び出し数と、やりとりの一覧(発言時刻・要約・応答時間、最大 60 件)。開始日/期日はタスクの開始・終了日 | +| **git 履歴から** | 指定したリポジトリのコミットをまとめてチケットにします。本文はコミットの一覧(日付・短縮ハッシュ・件名、最大 80 件) | + +git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs:` …)/ 期間まとめて 1 件** から選びます。 +分類は**件名の prefix と日付だけを見た機械的なもの**で、中身の判断はしません。期間は空にすると全履歴です。 + +下書きは件名をクリックすると本文を確認でき、チェックを外せば除外できます。 +起票先のプロジェクト・トラッカー・ステータス・担当・進捗 100% はダイアログ上部で指定します(次回も同じ値で開きます)。 + +- 起票は 1 件ずつ間隔を空けて流すので、**数十件だと数分かかります**。進捗と結果(チケット番号/失敗理由)はダイアログに出ます。実行中もダイアログは閉じられます。 +- Redmine は**作成時の状態指定を無視する**ため、状態を指定した場合は作成直後に更新をもう 1 回投げて付け直します(1 件あたり 2 回アクセスします)。 + ## 解析の仕様(ざっくり) - ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 @@ -117,8 +153,9 @@ python chktime.py ## ファイル ``` -chktime.py サーバ + jsonl 解析 +chktime.py サーバ + jsonl 解析 + Redmine 連携 web/index.html 画面 web/app.js 表示ロジック web/style.css スタイル +redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成) ``` diff --git a/chktime.py b/chktime.py index 85cd51a..6cb23d3 100644 --- a/chktime.py +++ b/chktime.py @@ -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}") diff --git a/web/app.js b/web/app.js index ab3f20b..71c9ba8 100644 --- a/web/app.js +++ b/web/app.js @@ -1080,6 +1080,388 @@ function stopFollow() { followStatus('off', ''); } +// ---------------------------------------------------------------- Redmine 起票 + +// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。 +// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。 +const rm = { + cfg: null, // /api/redmine/config (キーは含まれない) + meta: null, // プロジェクト / トラッカー / ステータス + settings: false, // 接続設定を開いているか + source: 'tasks', // tasks=chktime のタスク / git=git 履歴 + mode: 'day', // git のまとめ方: day / type / all + repo: '', + from: '', + to: '', + tasks: new Map(), // 起票対象のタスク: キー -> {project, id} + drafts: [], + off: new Set(), // 下書きのうち外したもの (index) + job: null, + timer: null, + busy: false, +}; + +async function post(path, payload) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + return data; +} + +/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */ +const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id }); +const taskKey = (t) => `${t.project}/${t.id}`; + +function rmMsg(text, kind) { + const el = $('#rm-msg'); + el.className = kind === 'err' ? 'err-inline' : 'note'; + el.textContent = text || ''; +} + +function rmConn() { + const el = $('#rm-conn'); + if (!rm.cfg || !rm.cfg.url) { + el.className = 'fstate wait'; + el.textContent = '未設定'; + } else if (!rm.cfg.hasKey) { + el.className = 'fstate wait'; + el.textContent = 'キー未設定'; + } else { + el.className = 'fstate live'; + el.textContent = rm.cfg.url; + } +} + +async function openRedmine() { + $('#rm-back').classList.remove('hidden'); + rmMsg(''); + try { + rm.cfg = await api('/api/redmine/config'); + rm.settings = !rm.cfg.url || !rm.cfg.hasKey; + if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : ''); + if (!rm.tasks.size && state.session) { + const t = { project: state.sessionProject, id: state.session }; + rm.tasks.set(taskKey(t), t); + } + renderRedmine(); + if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta(); + } catch (e) { + $('#rm-body').innerHTML = `

${esc(e.message)}

`; + } +} + +function closeRedmine() { + $('#rm-back').classList.add('hidden'); + clearInterval(rm.timer); + rm.timer = null; +} + +async function loadRedmineMeta() { + try { + rmMsg('Redmine に接続しています…'); + rm.meta = await api('/api/redmine/meta'); + rmMsg(''); + } catch (e) { + rm.meta = null; + rmMsg(e.message, 'err'); + } + renderRedmine(); +} + +function optionList(items, selected, label) { + return items + .map((it) => ``) + .join(''); +} + +function renderRedmine() { + rmConn(); + const cfg = rm.cfg || {}; + const meta = rm.meta; + + const settings = ` +
+

接続

+ ${rm.settings + ? `
+
+
+
+
+ + + +
+
設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。 + 自己署名証明書のサーバでは「証明書を検証する」を外してください。
` + : ''} +
`; + + const target = meta + ? `
+

起票先

+
+ + + + + + +
+
+ + +
+
` + : cfg.hasKey + ? '

Redmine の情報を取得できていません。接続設定を確認してください。

' + : ''; + + const tab = (v, label) => ``; + const picker = + rm.source === 'tasks' + ? `
+ 一覧に出ている ${state.sessions.length} 件から選びます + +
+
${ + state.sessions.length + ? state.sessions + .map((s) => { + const t = taskOf(s); + return `
`; + }) + .join('') + : '
タスクがありません
' + }
` + : `
+ +
+
+ 〜 + + +
+
分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。
`; + + const source = ` +
+

取得元 ${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}

+ ${picker} +
+
`; + + const drafts = rm.drafts.length + ? `
+

下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)

+
${rm.drafts + .map( + (d, i) => `
+
+ + + ${esc(d.subject)} + ${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''} + ${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''} + +
+
${esc(d.description)}
+
` + ) + .join('')}
+
` + : ''; + + $('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : ''); + wireRedmine(); + syncRun(); +} + +function renderJob() { + const j = rm.job; + const pct = j.total ? Math.round((j.done / j.total) * 100) : 0; + const ok = j.results.filter((r) => r.ok).length; + const ng = j.results.length - ok; + return `
+

起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}

+
+
成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}
+
${j.results + .map( + (r) => `
+ ${r.ok ? '#' + r.issueId : '失敗'} + ${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`} +
` + ) + .join('')}
+
`; +} + +/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */ +function wireRedmine() { + const on = (sel, ev, fn) => { + const el = $(sel); + if (el) el[ev] = fn; + }; + on('#rm-toggle', 'onclick', () => { + rm.settings = !rm.settings; + renderRedmine(); + }); + on('#rm-save', 'onclick', async () => { + try { + rmMsg('保存しています…'); + rm.cfg = await post('/api/redmine/config', { + url: $('#rm-url').value.trim(), + key: $('#rm-key').value.trim(), + verifySsl: $('#rm-verify').checked, + interval: Number($('#rm-int').value) || 7, + }); + rm.settings = false; + rm.meta = null; + rmMsg('保存しました'); + await loadRedmineMeta(); + } catch (e) { + rmMsg(e.message, 'err'); + } + }); + + document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => { + btn.onclick = () => { + rm.source = btn.dataset.src; + renderRedmine(); + }; + }); + document.querySelectorAll('#rm-body .rm-task').forEach((box) => { + box.onchange = () => { + const t = { project: box.dataset.project, id: box.dataset.id }; + if (box.checked) rm.tasks.set(taskKey(t), t); + else rm.tasks.delete(taskKey(t)); + }; + }); + const pickAll = (want) => { + state.sessions.forEach((s) => { + const t = taskOf(s); + if (want) rm.tasks.set(taskKey(t), t); + else rm.tasks.delete(taskKey(t)); + }); + renderRedmine(); + }; + on('#rm-all', 'onclick', () => pickAll(true)); + on('#rm-none', 'onclick', () => pickAll(false)); + on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim())); + on('#rm-from', 'onchange', (e) => (rm.from = e.target.value)); + on('#rm-to', 'onchange', (e) => (rm.to = e.target.value)); + on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value)); + on('#rm-draft', 'onclick', makeDrafts); + + document.querySelectorAll('#rm-body .rm-pick').forEach((box) => { + box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように + box.onchange = () => { + const i = Number(box.dataset.i); + if (box.checked) rm.off.delete(i); + else rm.off.add(i); + renderRedmine(); + }; + }); + document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => { + head.onclick = (e) => { + if (e.target.classList.contains('rm-pick')) return; + head.parentElement.classList.toggle('open'); + }; + }); + + ['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun)); +} + +/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */ +function syncRun() { + const proj = $('#rm-proj'); + $('#rm-run').disabled = + rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value); +} + +async function makeDrafts() { + const payload = + rm.source === 'tasks' + ? { + source: 'tasks', + ids: [...rm.tasks.values()], + } + : { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode }; + try { + rmMsg('下書きを作っています…'); + const data = await post('/api/redmine/draft', payload); + rm.drafts = data.drafts; + rm.off = new Set(); + rm.job = null; + rmMsg(`${rm.drafts.length} 件の下書きを作りました`); + } catch (e) { + rm.drafts = []; + rmMsg(e.message, 'err'); + } + renderRedmine(); +} + +async function runRedmine() { + const drafts = rm.drafts.filter((_, i) => !rm.off.has(i)); + if (!drafts.length) return; + const defaults = { + projectId: Number($('#rm-proj').value) || null, + trackerId: Number($('#rm-tracker').value) || null, + statusId: Number($('#rm-status').value) || null, + assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null, + doneRatio: $('#rm-done').checked ? 100 : null, + }; + const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1)); + if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return; + try { + rm.busy = true; + rmMsg('起票しています…'); + // 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す + post('/api/redmine/config', { + projectId: defaults.projectId, trackerId: defaults.trackerId, + statusId: defaults.statusId, assignedToId: defaults.assignedToId, + }).catch(() => {}); + rm.job = await post('/api/redmine/issues', { drafts, defaults }); + renderRedmine(); + clearInterval(rm.timer); + rm.timer = setInterval(pollJob, 1000); + } catch (e) { + rm.busy = false; + rmMsg(e.message, 'err'); + renderRedmine(); + } +} + +async function pollJob() { + if (!rm.job) return; + try { + rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`); + } catch { + return; + } + if (rm.job.state === 'done') { + clearInterval(rm.timer); + rm.timer = null; + rm.busy = false; + const ng = rm.job.results.filter((r) => !r.ok).length; + rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : ''); + } + renderRedmine(); +} + // ---------------------------------------------------------------- 起動 $('#filter').oninput = renderSessions; @@ -1090,6 +1472,15 @@ $('#relative').onchange = (e) => { $('#tab-turns').onclick = () => setView('turns'); $('#tab-daily').onclick = () => setView('daily'); $('#tab-gantt').onclick = () => setView('gantt'); +$('#redmine').onclick = openRedmine; +$('#rm-close').onclick = closeRedmine; +$('#rm-run').onclick = runRedmine; +$('#rm-back').onclick = (e) => { + if (e.target.id === 'rm-back') closeRedmine(); +}; +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine(); +}); $('#reload').onclick = async () => { await fetch('/api/reload'); const keep = { project: state.sessionProject, session: state.session, view: state.view }; diff --git a/web/index.html b/web/index.html index dfc0714..da6b98e 100644 --- a/web/index.html +++ b/web/index.html @@ -14,9 +14,27 @@ + + +