n8n 連携: 後回しにした小さな問題 4 件を修正

- タスク名・チェックの変更で画面全体を作り直さない (直後のクリックが失われていた)
- チケット選択の番号もエスケープする
- 長さ 0 分・24 時間超の区間は送れない理由を付け、画面でも送信でも外す
- items に dict でない項目があっても 500 にせず、その項目のエラーにする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 07:20:21 +09:00
co-authored by Claude Opus 5.5
parent 9a50480491
commit bcdd469d7a
3 changed files with 83 additions and 7 deletions
+14
View File
@@ -1220,6 +1220,7 @@ N8N_CONFIG = APP_DIR / "n8n.json"
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
N8N_MAX_BLOCK_SEC = 24 * 3600 # n8n の wt-import が受け付ける区間の長さの上限
N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない
@@ -1268,6 +1269,12 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
minutes = (group["end"] - group["start"]) / 60.0
if minutes < min_min and not in_progress:
continue
# n8n の受け口が断る長さは、ここで送れない理由として付けておく
problem = None
if group["end"] <= group["start"]:
problem = "長さが 0 分です"
elif group["end"] - group["start"] > N8N_MAX_BLOCK_SEC:
problem = "24 時間を超えています"
out.append(
{
"source": block_source(session["id"], group["start"]),
@@ -1280,6 +1287,7 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"minutes": int(round(minutes)),
"turns": group["turns"],
"inProgress": in_progress,
"problem": problem,
}
)
return out
@@ -1545,6 +1553,9 @@ def n8n_send(store, holder, payload, now=None):
gap, minimum = holder.settings()
rows, errors, cache = [], [], {}
for item in items:
if not isinstance(item, dict):
errors.append({"source": "", "error": "項目の形が不正です"})
continue
source = str(item.get("source") or "")
task = " ".join(str(item.get("task") or "").split())
match = SOURCE_RE.match(source)
@@ -1570,6 +1581,9 @@ def n8n_send(store, holder, payload, now=None):
if block["inProgress"]:
errors.append({"source": source, "error": "進行中の区間は送れません"})
continue
if block["problem"]:
errors.append({"source": source, "error": block["problem"]})
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}
+44
View File
@@ -525,6 +525,50 @@ class FinalReviewFixTest(unittest.TestCase):
self.assertTrue(holder.update({"url": "https://other.example", "token": "secret2"})["hasToken"])
class MinorFixTest(unittest.TestCase):
"""後回しにした小さな問題の再現テスト。"""
def test_zero_length_block_has_problem(self):
blocks = chktime.make_blocks(session(turn(0, 0)), min_min=0, now=LATER)
self.assertEqual(blocks[0]["problem"], "長さが 0 分です")
def test_over_24h_block_has_problem(self):
turns = [turn(m, m + 20) for m in range(0, 25 * 60, 40)] # 40 分ごとにつながって 25 時間
blocks = chktime.make_blocks(session(*turns), now=LATER + 2 * 86400)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["problem"], "24 時間を超えています")
def test_normal_block_has_no_problem(self):
self.assertIsNone(chktime.make_blocks(session(turn(0, 10)), now=LATER)[0]["problem"])
class MinorSendTest(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", "設計して"), (0, "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, "minMinutes": 0})
def tearDown(self):
self.fake.close()
self.tmp.cleanup()
def test_send_rejects_block_with_problem(self):
item = {"project": "C--debug-demo", "source": f"chktime:s1:{int(round(BASE * 1000))}", "task": "x"}
result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER)
self.assertEqual(result["errors"][0]["error"], "長さが 0 分です")
self.assertEqual([r for r in self.fake.requests if r[0] == "POST"], [])
def test_send_reports_non_dict_item(self):
result = chktime.n8n_send(self.store, self.holder, {"items": ["abc", 3]}, now=LATER)
self.assertFalse(result["ok"])
self.assertEqual([e["error"] for e in result["errors"]], ["項目の形が不正です", "項目の形が不正です"])
class PostGuardTest(unittest.TestCase):
"""Important 4: 他のサイトから POST で設定を書き換えられないこと (Content-Type と Origin)。"""
+25 -7
View File
@@ -38,7 +38,7 @@ function n8SaveName(sessionId, value) {
}
}
const n8CanSend = (b) => !b.sent && !b.inProgress;
const n8CanSend = (b) => !b.sent && !b.inProgress && !b.problem;
const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length;
function n8Query() {
@@ -78,6 +78,7 @@ function n8Status(b) {
if (b.changedSinceSent) marks.push('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</span>');
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
if (b.problem) marks.push(`<span class="n8-mark warn" title="n8n が受け付けないので送れません">${esc(b.problem)}</span>`);
if ((b.overlaps || []).length) {
const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n');
marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
@@ -112,7 +113,7 @@ function n8ResultHtml() {
function n8PickHtml() {
if (!n8.pick) return '';
const rows = n8.issues
.map((i) => `<li><button class="n8-issue" data-id="${i.id}">#${i.id}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
.map((i) => `<li><button class="n8-issue" data-id="${esc(i.id)}">#${esc(i.id)}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
.join('');
return `<div class="n8-pick">
<h4>Redmine のチケットを選ぶ</h4>
@@ -149,7 +150,7 @@ function renderN8n() {
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
<span class="spacer"></span>
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button>
<button id="n8-send" class="go"></button>
</div>
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
${n8.settings ? n8Settings() : ''}
@@ -160,18 +161,36 @@ function renderN8n() {
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
</div>`;
n8Wire();
n8SyncSend();
}
/** 送信ボタンの件数と押せるかどうかだけを更新する (ボタン自体は作り直さない)。 */
function n8SyncSend() {
const btn = $('#n8-send');
if (!btn || !n8.data) return;
const on = n8.data.blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)).length;
btn.textContent = `選んだ ${on} 件を送る`;
btn.disabled = Boolean(n8.data.n8nError) || n8.busy || !on;
}
function n8Block(source) {
return n8.data.blocks.find((b) => b.source === source);
}
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。
* 画面は作り直さず、変わった行の入力欄だけ書き換える (作り直すと直後のクリックが失われるため)。 */
function n8SetEdit(source, patch) {
const b = n8Block(source);
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b];
for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
n8SaveName(b.sessionId, n8.edits.get(source));
const changed = new Set(targets.map((t) => t.source));
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
if (!changed.has(tr.dataset.src)) return;
const e = n8.edits.get(tr.dataset.src);
if ('task' in patch) tr.querySelector('.n8-task').value = e.task;
if ('memo' in patch) tr.querySelector('.n8-memo').value = e.memo;
});
}
function n8Wire() {
@@ -203,7 +222,7 @@ function n8Wire() {
const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id);
n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` });
n8.pick = null;
renderN8n();
renderN8n(); // 選択の枠を閉じるので作り直す
};
});
}
@@ -212,11 +231,10 @@ function n8Wire() {
tr.querySelector('.n8-on').onchange = (e) => {
if (e.target.checked) n8.checked.add(source);
else n8.checked.delete(source);
renderN8n();
n8SyncSend();
};
tr.querySelector('.n8-task').onchange = (e) => {
n8SetEdit(source, { task: e.target.value });
renderN8n();
};
tr.querySelector('.n8-memo').onchange = (e) => {
n8SetEdit(source, { memo: e.target.value });