Claude の作業区間を n8n の work_log へ送る「n8n へ送る」タブを追加 #2
+117
-6
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user