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
+117 -6
View File
@@ -32,7 +32,7 @@ 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
from pathlib import Path 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 APP_DIR = Path(__file__).resolve().parent
WEB_DIR = APP_DIR / "web" WEB_DIR = APP_DIR / "web"
@@ -925,6 +925,23 @@ class Redmine:
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}}) self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
return issue_id 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): def load_redmine_config(path):
try: try:
@@ -1361,9 +1378,10 @@ class N8n:
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res: with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res:
raw = res.read() raw = res.read()
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code == 403: with exc:
raise N8nError("合言葉が違います (HTTP 403)") from None if exc.code == 403:
detail = one_line(exc.read().decode("utf-8", "replace"), 200) 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 raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise N8nError(f"接続できません: {exc.reason}") from None raise N8nError(f"接続できません: {exc.reason}") from None
@@ -1444,6 +1462,87 @@ class N8nHolder:
return self.public() 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 # ---------------------------------------------------------------- HTTP
@@ -1559,7 +1658,7 @@ 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: except (RedmineError, N8nError) as exc:
self.send_json({"error": str(exc)}, 400) 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)
@@ -1572,7 +1671,7 @@ class Handler(BaseHTTPRequestHandler):
raise FileNotFoundError(route) raise FileNotFoundError(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, ValueError) as exc: except (RedmineError, N8nError, ValueError) as exc:
self.send_json({"error": str(exc)}, 400) 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)
@@ -1589,6 +1688,10 @@ class Handler(BaseHTTPRequestHandler):
def api_post(self, route, payload): def api_post(self, route, payload):
redmine = self.server.redmine 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": if route == "/api/redmine/config":
return self.send_json(redmine.update(payload)) return self.send_json(redmine.update(payload))
if route == "/api/redmine/draft": if route == "/api/redmine/draft":
@@ -1642,6 +1745,12 @@ 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/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": if route == "/api/redmine/config":
return self.send_json(self.server.redmine.public()) return self.send_json(self.server.redmine.public())
if route == "/api/redmine/meta": 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("--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("--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") parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -1763,6 +1873,7 @@ def main():
return return
server.store = Store(root) server.store = Store(root)
server.redmine = RedmineHolder(args.redmine_config) server.redmine = RedmineHolder(args.redmine_config)
server.n8n = N8nHolder(args.n8n_config)
server.jobs = Jobs() server.jobs = Jobs()
server.watcher = Watcher(root, args.interval) server.watcher = Watcher(root, args.interval)
server.watcher.start() server.watcher.start()
+121
View File
@@ -276,5 +276,126 @@ class N8nHolderTest(unittest.TestCase):
holder.update(patch) 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__": if __name__ == "__main__":
unittest.main() unittest.main()