diff --git a/chktime.py b/chktime.py index fd81a33..c7f9378 100644 --- a/chktime.py +++ b/chktime.py @@ -32,7 +32,7 @@ import webbrowser from datetime import date, datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, urlparse APP_DIR = Path(__file__).resolve().parent WEB_DIR = APP_DIR / "web" @@ -925,6 +925,23 @@ class Redmine: self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}}) return issue_id + def search(self, text, limit=30): + """未完了チケットを件名で探す (n8n へ送るときのチケット選択用)。数字だけなら番号で引く。""" + text = (text or "").strip() + if text.isdigit(): + try: + issue = self.call(f"/issues/{text}.json").get("issue") + except RedmineError: + issue = None + items = [issue] if issue else [] + else: + path = f"/issues.json?status_id=open&sort=updated_on:desc&limit={int(limit)}" + if text: + path += "&f[]=subject&op[subject]=~&v[subject][]=" + quote(text) + items = self.call(path).get("issues") or [] + return [{"id": i.get("id"), "subject": i.get("subject") or "", + "project": (i.get("project") or {}).get("name") or ""} for i in items] + def load_redmine_config(path): try: @@ -1361,9 +1378,10 @@ class N8n: with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res: raw = res.read() except urllib.error.HTTPError as exc: - if exc.code == 403: - raise N8nError("合言葉が違います (HTTP 403)") from None - detail = one_line(exc.read().decode("utf-8", "replace"), 200) + with exc: + if exc.code == 403: + raise N8nError("合言葉が違います (HTTP 403)") from None + detail = one_line(exc.read().decode("utf-8", "replace"), 200) raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None except urllib.error.URLError as exc: raise N8nError(f"接続できません: {exc.reason}") from None @@ -1444,6 +1462,87 @@ class N8nHolder: return self.public() +SOURCE_RE = re.compile(r"^chktime:([^:]+):(\d+)$") + + +def n8n_range(query): + """?from= / ?to= の解釈。既定は今日までの 7 日。逆順と N8N_MAX_DAYS 日超はエラー。""" + end = parse_date(query.get("to", [""])[0]) or date.today() + start = parse_date(query.get("from", [""])[0]) or end - timedelta(days=6) + if start > end: + raise N8nError("開始日が終了日より後です") + if (end - start).days + 1 > N8N_MAX_DAYS: + raise N8nError(f"期間は {N8N_MAX_DAYS} 日以内にしてください") + return start, end + + +def n8n_blocks(store, holder, query, now=None): + """期間の作業区間に、n8n 側の送信済み・削除済み・重なりを付けて返す。 + + n8n に届かなくても区間は返し、n8nError に理由を入れる (送信済みは分からないので UI は送らせない)。 + """ + start, end = n8n_range(query) + gap, minimum = holder.settings() + blocks = collect_blocks(store, query.get("project", [""])[0], start, end, gap, minimum, now) + rows, deleted, error = [], [], None + try: + data = holder.client.rows(start, end) + rows = data.get("rows") or [] + deleted = data.get("deletedSources") or [] + except N8nError as exc: + error = str(exc) + mark_blocks(blocks, rows, deleted, now) + return {"from": start.isoformat(), "to": end.isoformat(), "gapMinutes": gap, "minMinutes": minimum, + "blocks": blocks, "n8nError": error} + + +def n8n_send(store, holder, payload, now=None): + """選ばれた区間を n8n の work_log に送る。時刻はブラウザの値を使わずログから計算し直す。""" + items = payload.get("items") or [] + if not items: + raise N8nError("送る区間がありません") + if len(items) > 200: + raise N8nError("一度に送れるのは 200 件までです") + gap, minimum = holder.settings() + rows, errors, cache = [], [], {} + for item in items: + source = str(item.get("source") or "") + task = " ".join(str(item.get("task") or "").split()) + match = SOURCE_RE.match(source) + if not match: + errors.append({"source": source, "error": "source が不正です"}) + continue + if not task: + errors.append({"source": source, "error": "タスク名が空です"}) + continue + key = (str(item.get("project") or ""), match.group(1)) + if key not in cache: + try: + cache[key] = make_blocks(store.one(*key), gap, minimum, now) + except FileNotFoundError: + cache[key] = None + if cache[key] is None: + errors.append({"source": source, "error": "セッションが見つかりません"}) + continue + block = next((b for b in cache[key] if b["source"] == source), None) + if block is None: + errors.append({"source": source, "error": "区間が見つかりません (ログか設定が変わりました)"}) + continue + if block["inProgress"]: + errors.append({"source": source, "error": "進行中の区間は送れません"}) + continue + rows.append({"source": source, "date": block["date"], "start_time": block["start"], + "end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")}) + result = {"ok": False, "inserted": [], "skipped": [], "errors": errors} + if rows: + data = holder.client.import_rows(rows) + result["inserted"] = data.get("inserted") or [] + result["skipped"] = data.get("skipped") or [] + result["errors"] = errors + (data.get("errors") or []) + result["ok"] = not result["errors"] + return result + + # ---------------------------------------------------------------- HTTP @@ -1559,7 +1658,7 @@ class Handler(BaseHTTPRequestHandler): return self.static(route) except FileNotFoundError as exc: self.send_json({"error": f"not found: {exc}"}, 404) - except RedmineError as exc: + except (RedmineError, N8nError) 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) @@ -1572,7 +1671,7 @@ class Handler(BaseHTTPRequestHandler): raise FileNotFoundError(route) except FileNotFoundError as exc: self.send_json({"error": f"not found: {exc}"}, 404) - except (RedmineError, ValueError) as exc: + except (RedmineError, N8nError, 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) @@ -1589,6 +1688,10 @@ class Handler(BaseHTTPRequestHandler): def api_post(self, route, payload): redmine = self.server.redmine + if route == "/api/n8n/config": + return self.send_json(self.server.n8n.update(payload)) + if route == "/api/n8n/send": + return self.send_json(n8n_send(self.server.store, self.server.n8n, payload)) if route == "/api/redmine/config": return self.send_json(redmine.update(payload)) if route == "/api/redmine/draft": @@ -1642,6 +1745,12 @@ 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/n8n/config": + return self.send_json(self.server.n8n.public()) + if route == "/api/n8n/blocks": + return self.send_json(n8n_blocks(store, self.server.n8n, query)) + if route == "/api/redmine/search": + return self.send_json({"issues": self.server.redmine.client.search(query.get("q", [""])[0])}) if route == "/api/redmine/config": return self.send_json(self.server.redmine.public()) if route == "/api/redmine/meta": @@ -1743,6 +1852,7 @@ def main(): 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("--n8n-config", default=str(N8N_CONFIG), help="n8n 連携の設定ファイル") parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() @@ -1763,6 +1873,7 @@ def main(): return server.store = Store(root) server.redmine = RedmineHolder(args.redmine_config) + server.n8n = N8nHolder(args.n8n_config) server.jobs = Jobs() server.watcher = Watcher(root, args.interval) server.watcher.start() diff --git a/tests/test_n8n.py b/tests/test_n8n.py index b52c861..e76e9c5 100644 --- a/tests/test_n8n.py +++ b/tests/test_n8n.py @@ -276,5 +276,126 @@ class N8nHolderTest(unittest.TestCase): holder.update(patch) +class ApiFunctionsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "projects" + self.store = chktime.Store(self.root) + write_session(self.root, "C--debug-demo", "s1", + [(0, "user", "設計して"), (10, "assistant", "ok")], r"C:\debug\demo") + self.fake = FakeN8n() + self.holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json") + self.holder.update({"url": self.fake.url, "token": FakeN8n.TOKEN}) + self.query = {"from": ["2026-01-05"], "to": ["2026-01-05"], "project": [""]} + + def tearDown(self): + self.fake.close() + self.tmp.cleanup() + + def source(self): + return f"chktime:s1:{int(round(BASE * 1000))}" + + def test_range_defaults_and_limits(self): + start, end = chktime.n8n_range({}) + self.assertEqual((end - start).days, 6) + with self.assertRaises(chktime.N8nError): + chktime.n8n_range({"from": ["2026-01-05"], "to": ["2026-01-04"]}) + with self.assertRaises(chktime.N8nError): + chktime.n8n_range({"from": ["2026-01-01"], "to": ["2026-03-31"]}) + + def test_blocks_marks_with_n8n_rows(self): + data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER) + self.assertIsNone(data["n8nError"]) + self.assertEqual(len(data["blocks"]), 1) + self.assertFalse(data["blocks"][0]["sent"]) + + def test_blocks_when_n8n_down(self): + self.holder.update({"url": "http://127.0.0.1:9"}) + data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER) + self.assertIn("接続できません", data["n8nError"]) + self.assertEqual(len(data["blocks"]), 1) + self.assertFalse(data["blocks"][0]["sent"]) + + def test_send_recomputes_times_on_server(self): + item = {"project": "C--debug-demo", "source": self.source(), "task": "[demo] #12 設計", "memo": "m", + "start_time": "1999-01-01T00:00:00Z"} # ブラウザの時刻は使わない + result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER) + self.assertTrue(result["ok"]) + method, path, body = self.fake.requests[-1] + row = body["rows"][0] + self.assertEqual(path, "/webhook/wt-import") + self.assertEqual(row["start_time"], chktime.iso(BASE)) + self.assertEqual(row["end_time"], chktime.iso(BASE + 10 * MIN)) + self.assertEqual(row["date"], "2026-01-05") + self.assertEqual(row["task"], "[demo] #12 設計") + + def test_send_rejects_blank_task(self): + item = {"project": "C--debug-demo", "source": self.source(), "task": " "} + result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER) + self.assertFalse(result["ok"]) + self.assertEqual(result["errors"][0]["error"], "タスク名が空です") + self.assertEqual([r for r in self.fake.requests if r[0] == "POST"], []) + + def test_send_rejects_unknown_and_in_progress(self): + bad = {"project": "C--debug-demo", "source": "chktime:s1:123", "task": "x"} + gone = {"project": "C--debug-demo", "source": "chktime:nope:1", "task": "x"} + result = chktime.n8n_send(self.store, self.holder, {"items": [bad, gone]}, now=LATER) + self.assertEqual([e["error"] for e in result["errors"]], + ["区間が見つかりません (ログか設定が変わりました)", "セッションが見つかりません"]) + live = {"project": "C--debug-demo", "source": self.source(), "task": "x"} + result = chktime.n8n_send(self.store, self.holder, {"items": [live]}, now=BASE + 11 * MIN) + self.assertEqual(result["errors"][0]["error"], "進行中の区間は送れません") + + def test_send_requires_items(self): + with self.assertRaises(chktime.N8nError): + chktime.n8n_send(self.store, self.holder, {"items": []}, now=LATER) + + +class RedmineSearchTest(unittest.TestCase): + """チケット選択用の検索。偽の Redmine に対して要求の形と結果の変換を確かめる。""" + + def setUp(self): + self.paths = [] + test = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + test.paths.append(self.path) + if self.path.startswith("/issues/404.json"): + self.send_response(404) + self.end_headers() + return + if self.path.startswith("/issues/"): + payload = {"issue": {"id": 7, "subject": "七", "project": {"name": "P"}}} + else: + payload = {"issues": [{"id": 3, "subject": "設計", "project": {"name": "ImgChk"}}]} + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.client = chktime.Redmine(f"http://127.0.0.1:{self.server.server_address[1]}", "key", interval=0) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def test_search_by_subject(self): + self.assertEqual(self.client.search("設 計"), [{"id": 3, "subject": "設計", "project": "ImgChk"}]) + self.assertIn("status_id=open", self.paths[-1]) + self.assertIn("op[subject]=~", self.paths[-1]) + self.assertIn("v[subject][]=%E8%A8%AD%20%E8%A8%88", self.paths[-1]) + + def test_search_by_number_and_missing_number(self): + self.assertEqual(self.client.search("7"), [{"id": 7, "subject": "七", "project": "P"}]) + self.assertEqual(self.client.search("404"), []) + + if __name__ == "__main__": unittest.main()