n8n 連携: 区間の一覧・送信・設定の API と Redmine のチケット検索を追加

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 03:15:31 +09:00
co-authored by Claude Opus 5.5
parent c1d1d046b6
commit 682820ae43
2 changed files with 238 additions and 6 deletions
+114 -3
View File
@@ -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,6 +1378,7 @@ class N8n:
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
with exc:
if exc.code == 403:
raise N8nError("合言葉が違います (HTTP 403)") from None
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
@@ -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()
+121
View File
@@ -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()