n8n 連携: 区間の一覧・送信・設定の API と Redmine のチケット検索を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+114
-3
@@ -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,6 +1378,7 @@ 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:
|
||||||
|
with exc:
|
||||||
if exc.code == 403:
|
if exc.code == 403:
|
||||||
raise N8nError("合言葉が違います (HTTP 403)") from None
|
raise N8nError("合言葉が違います (HTTP 403)") from None
|
||||||
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
|
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user