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:
@@ -5,3 +5,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Claude Code のローカル設定
|
# Claude Code のローカル設定
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Redmine 連携の設定 (API アクセスキーが入る)
|
||||||
|
redmine.json
|
||||||
|
|||||||
@@ -6,20 +6,25 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ
|
|||||||
## 構成
|
## 構成
|
||||||
|
|
||||||
```
|
```
|
||||||
chktime.py サーバ + jsonl 解析(これ1本で完結)
|
chktime.py サーバ + jsonl 解析 + Redmine 連携(これ1本で完結)
|
||||||
web/index.html 画面の骨組み
|
web/index.html 画面の骨組み
|
||||||
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
|
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
|
||||||
web/style.css スタイル(ダークテーマ固定)
|
web/style.css スタイル(ダークテーマ固定)
|
||||||
run.bat コンソール表示で起動
|
run.bat コンソール表示で起動
|
||||||
stop.cmd 起動中の chktime を終了
|
stop.cmd 起動中の chktime を終了
|
||||||
|
redmine.json Redmine 連携の設定(自動生成・git 管理外)
|
||||||
```
|
```
|
||||||
|
|
||||||
`chktime.py` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。
|
`chktime.py` の中身は上から
|
||||||
|
「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ Redmine 連携 → HTTP」の順。
|
||||||
|
|
||||||
## 守ること
|
## 守ること
|
||||||
|
|
||||||
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
||||||
- **外部通信しない**。読むのはローカルの jsonl だけ。
|
- **外部通信は Redmine 連携だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。
|
||||||
|
新しい通信先を増やすときは、この行を書き換えるところから始める。
|
||||||
|
- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない
|
||||||
|
(`/api/redmine/config` はキーの有無だけ返す)。
|
||||||
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
|
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
|
||||||
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
|
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
|
||||||
- コメント・コミットメッセージ・UI の文言は日本語。
|
- コメント・コミットメッセージ・UI の文言は日本語。
|
||||||
@@ -50,6 +55,16 @@ python chktime.py --port 8799 --no-browser
|
|||||||
|
|
||||||
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
|
いずれも `&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`)は起点にしない。
|
- ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。
|
||||||
@@ -70,3 +85,17 @@ python chktime.py --port 8799 --no-browser
|
|||||||
ターン単位で共有している。
|
ターン単位で共有している。
|
||||||
- `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、
|
- `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、
|
||||||
`clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。
|
`clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。
|
||||||
|
- Redmine 起票のダイアログは `rm` オブジェクトが状態を持ち、`renderRedmine()` が中身を作り直す。
|
||||||
|
HTML 属性に NUL は入れられない(パーサが置換する)ので、タスクの識別は `data-project` /
|
||||||
|
`data-id` の 2 属性で持つ。
|
||||||
|
|
||||||
|
## Redmine 連携の落とし穴
|
||||||
|
|
||||||
|
実際に踏んだもの。同じところで転ばないように。
|
||||||
|
|
||||||
|
- **作成時の `status_id` は無視される**(既定の New で入る)。状態を指定したいなら作成後に
|
||||||
|
`PUT /issues/<id>.json` で付け直す(`Redmine.create()` がやっている)。
|
||||||
|
- **連続アクセスに 403 を返すサーバがある**。7〜8 秒あければ通る。`Redmine.throttle()` が
|
||||||
|
クライアント単位で間隔を保つので、クライアントは作り直さず使い回す。
|
||||||
|
- **自己署名証明書**が普通なので、既定は検証なし(`verifySsl: false`)。
|
||||||
|
- 起票は件数×間隔ぶん時間がかかるので HTTP を掴んだままにしない。`Jobs` に投げて進捗を取りに来てもらう。
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ Claude Code の **タスク(セッション)ごとのチャットのやり
|
|||||||
|
|
||||||
`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(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 | 待ち受けアドレス |
|
| `--host` | 127.0.0.1 | 待ち受けアドレス |
|
||||||
| `--dir` | `~/.claude/projects` | ログの置き場所 |
|
| `--dir` | `~/.claude/projects` | ログの置き場所 |
|
||||||
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
|
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
|
||||||
|
| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル |
|
||||||
| `--no-browser` | – | ブラウザを自動で開かない |
|
| `--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/index.html 画面
|
||||||
web/app.js 表示ロジック
|
web/app.js 表示ロジック
|
||||||
web/style.css スタイル
|
web/style.css スタイル
|
||||||
|
redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成)
|
||||||
```
|
```
|
||||||
|
|||||||
+446
@@ -22,8 +22,12 @@ import os
|
|||||||
import re
|
import re
|
||||||
import select
|
import select
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -797,6 +801,396 @@ def decode_project_dir(name):
|
|||||||
return name.replace("-", "/")
|
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
|
# ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
|
||||||
@@ -912,9 +1306,49 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.static(route)
|
return self.static(route)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
self.send_json({"error": f"not found: {exc}"}, 404)
|
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 にエラーを返す
|
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
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):
|
def api(self, route, query):
|
||||||
store = self.server.store
|
store = self.server.store
|
||||||
if route == "/api/projects":
|
if route == "/api/projects":
|
||||||
@@ -955,6 +1389,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if query.get("format", [""])[0] == "csv":
|
if query.get("format", [""])[0] == "csv":
|
||||||
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
|
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
|
||||||
return self.send_json(session)
|
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":
|
if route == "/api/reload":
|
||||||
store.clear()
|
store.clear()
|
||||||
return self.send_json({"ok": True})
|
return self.send_json({"ok": True})
|
||||||
@@ -1046,6 +1489,7 @@ def main():
|
|||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
|
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("--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")
|
parser.add_argument("--no-browser", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1065,6 +1509,8 @@ def main():
|
|||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
return
|
return
|
||||||
server.store = Store(root)
|
server.store = Store(root)
|
||||||
|
server.redmine = RedmineHolder(args.redmine_config)
|
||||||
|
server.jobs = Jobs()
|
||||||
server.watcher = Watcher(root, args.interval)
|
server.watcher = Watcher(root, args.interval)
|
||||||
server.watcher.start()
|
server.watcher.start()
|
||||||
print(f"chktime: {root}")
|
print(f"chktime: {root}")
|
||||||
|
|||||||
+391
@@ -1080,6 +1080,388 @@ function stopFollow() {
|
|||||||
followStatus('off', '');
|
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRedmine() {
|
||||||
|
rmConn();
|
||||||
|
const cfg = rm.cfg || {};
|
||||||
|
const meta = rm.meta;
|
||||||
|
|
||||||
|
const settings = `
|
||||||
|
<div class="rm-sec">
|
||||||
|
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
|
||||||
|
${rm.settings
|
||||||
|
? `<div class="rm-row"><label>URL</label>
|
||||||
|
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
|
||||||
|
<div class="rm-row"><label>APIキー</label>
|
||||||
|
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
|
||||||
|
<div class="rm-row">
|
||||||
|
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
|
||||||
|
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
|
||||||
|
<button id="rm-save">保存</button>
|
||||||
|
</div>
|
||||||
|
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
|
||||||
|
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
|
||||||
|
: ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const target = meta
|
||||||
|
? `<div class="rm-sec">
|
||||||
|
<h4>起票先</h4>
|
||||||
|
<div class="rm-row">
|
||||||
|
<label>プロジェクト</label>
|
||||||
|
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
|
||||||
|
<label>トラッカー</label>
|
||||||
|
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
|
||||||
|
<label>ステータス</label>
|
||||||
|
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="rm-row">
|
||||||
|
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: cfg.hasKey
|
||||||
|
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
|
||||||
|
const picker =
|
||||||
|
rm.source === 'tasks'
|
||||||
|
? `<div class="rm-row">
|
||||||
|
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
|
||||||
|
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
|
||||||
|
</div>
|
||||||
|
<div class="rm-list">${
|
||||||
|
state.sessions.length
|
||||||
|
? state.sessions
|
||||||
|
.map((s) => {
|
||||||
|
const t = taskOf(s);
|
||||||
|
return `<div class="rm-item"><label class="h">
|
||||||
|
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
|
||||||
|
<span class="t"><span class="subject">${esc(s.title)}</span>
|
||||||
|
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
|
||||||
|
</label></div>`;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
|
||||||
|
}</div>`
|
||||||
|
: `<div class="rm-row">
|
||||||
|
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
|
||||||
|
</div>
|
||||||
|
<div class="rm-row">
|
||||||
|
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
|
||||||
|
<label>まとめ方</label>
|
||||||
|
<select id="rm-mode">
|
||||||
|
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
|
||||||
|
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
|
||||||
|
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
|
||||||
|
|
||||||
|
const source = `
|
||||||
|
<div class="rm-sec">
|
||||||
|
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
|
||||||
|
${picker}
|
||||||
|
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const drafts = rm.drafts.length
|
||||||
|
? `<div class="rm-sec">
|
||||||
|
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
|
||||||
|
<div class="rm-list">${rm.drafts
|
||||||
|
.map(
|
||||||
|
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
|
||||||
|
<div class="h">
|
||||||
|
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
|
||||||
|
<span class="t">
|
||||||
|
<span class="subject">${esc(d.subject)}</span>
|
||||||
|
<span class="meta">${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} コミット` : ''}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">${esc(d.description)}</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')}</div>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$('#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 `<div class="rm-sec">
|
||||||
|
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
|
||||||
|
<div class="rm-prog"><i style="width:${pct}%"></i></div>
|
||||||
|
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
|
||||||
|
<div class="rm-list" style="margin-top:6px">${j.results
|
||||||
|
.map(
|
||||||
|
(r) => `<div class="rm-res">
|
||||||
|
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
|
||||||
|
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
|
||||||
|
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;
|
$('#filter').oninput = renderSessions;
|
||||||
@@ -1090,6 +1472,15 @@ $('#relative').onchange = (e) => {
|
|||||||
$('#tab-turns').onclick = () => setView('turns');
|
$('#tab-turns').onclick = () => setView('turns');
|
||||||
$('#tab-daily').onclick = () => setView('daily');
|
$('#tab-daily').onclick = () => setView('daily');
|
||||||
$('#tab-gantt').onclick = () => setView('gantt');
|
$('#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 () => {
|
$('#reload').onclick = async () => {
|
||||||
await fetch('/api/reload');
|
await fetch('/api/reload');
|
||||||
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
||||||
|
|||||||
@@ -14,9 +14,27 @@
|
|||||||
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
|
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
|
||||||
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
|
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
|
||||||
<span id="follow-state" class="fstate"></span>
|
<span id="follow-state" class="fstate"></span>
|
||||||
|
<button id="redmine" title="Redmine にチケットを起票する">Redmine へ起票</button>
|
||||||
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
|
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="rm-back" class="modal-back hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span>Redmine へ起票</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span id="rm-conn" class="fstate"></span>
|
||||||
|
<button id="rm-close" title="閉じる">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="rm-body"></div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<span id="rm-msg" class="note"></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="rm-run" class="go">起票する</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<nav id="projects" class="pane">
|
<nav id="projects" class="pane">
|
||||||
<div class="pane-head">プロジェクト</div>
|
<div class="pane-head">プロジェクト</div>
|
||||||
|
|||||||
@@ -297,6 +297,98 @@ table { width: 100%; border-collapse: collapse; }
|
|||||||
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
|
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
|
||||||
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
|
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
|
||||||
|
|
||||||
|
/* Redmine 起票ダイアログ */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.modal-back {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, .55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(880px, 94vw);
|
||||||
|
height: min(760px, 92vh);
|
||||||
|
background: var(--pane);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-head, .modal-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--pane2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.modal-foot { border-bottom: none; border-top: 1px solid var(--line); }
|
||||||
|
.modal-head .spacer, .modal-foot .spacer { flex: 1; }
|
||||||
|
.modal-body { flex: 1; overflow: auto; padding: 12px 14px 20px; }
|
||||||
|
.modal .note { color: var(--dim); font-size: 11px; }
|
||||||
|
.modal .err-inline { color: #f7768e; font-size: 11px; }
|
||||||
|
.modal .go { border-color: var(--work); color: var(--work); }
|
||||||
|
.modal .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
|
||||||
|
|
||||||
|
.rm-sec { margin-bottom: 14px; }
|
||||||
|
.rm-sec > h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.rm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.rm-row label { color: var(--dim); font-size: 11px; }
|
||||||
|
.modal input[type="text"], .modal input[type="password"], .modal input[type="date"], .modal select {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
.modal input[type="text"], .modal input[type="password"] { flex: 1 1 220px; min-width: 140px; }
|
||||||
|
.modal input:focus, .modal select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.rm-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||||
|
.rm-item { border-bottom: 1px solid var(--line); }
|
||||||
|
.rm-item:last-child { border-bottom: none; }
|
||||||
|
.rm-item > .h { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; cursor: pointer; }
|
||||||
|
.rm-item > .h:hover { background: var(--pane2); }
|
||||||
|
.rm-item .t { flex: 1; min-width: 0; }
|
||||||
|
.rm-item .subject { font-size: 12.5px; word-break: break-word; }
|
||||||
|
.rm-item .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
||||||
|
.rm-item .body {
|
||||||
|
display: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: var(--bg);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.rm-item.open .body { display: block; }
|
||||||
|
.rm-item.off .subject, .rm-item.off .meta { opacity: .45; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.rm-res { display: flex; gap: 8px; padding: 4px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
||||||
|
.rm-res:last-child { border-bottom: none; }
|
||||||
|
.rm-res .id { color: var(--work); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.rm-res .id.ng { color: #f7768e; }
|
||||||
|
.rm-res .s { flex: 1; min-width: 0; word-break: break-word; }
|
||||||
|
.rm-prog { height: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0; }
|
||||||
|
.rm-prog i { display: block; height: 100%; background: var(--work); transition: width .2s; }
|
||||||
|
|
||||||
/* ガント (ターン単位) */
|
/* ガント (ターン単位) */
|
||||||
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.gantt svg rect.t-wait { fill: var(--user); }
|
.gantt svg rect.t-wait { fill: var(--user); }
|
||||||
|
|||||||
Reference in New Issue
Block a user