From 9d65567603a459e1f88e5edeea26d97fa3d3ea20 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Thu, 24 Sep 2026 15:04:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=90=88=E5=B9=B6PR?= =?UTF-8?q?=E8=A1=A5=E6=9F=A5=E5=85=B3=E8=81=94=E4=B8=8E=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=8E=A8=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloudflare/automation/src/index.js | 12 +++-- cloudflare/automation/src/online-runtime.js | 47 +++++++++++++++---- cloudflare/automation/src/task-board.js | 45 ++++++++++++++---- .../automation/test/online-routing.test.js | 42 +++++++++++++++-- .../automation/test/task-routing.test.js | 41 ++++++++++++++-- .../test/task-update-routing.test.js | 16 +++++++ 6 files changed, 172 insertions(+), 31 deletions(-) diff --git a/cloudflare/automation/src/index.js b/cloudflare/automation/src/index.js index 7ac28b0..943daf3 100644 --- a/cloudflare/automation/src/index.js +++ b/cloudflare/automation/src/index.js @@ -165,13 +165,15 @@ export function taskCompletionState(outcome) { return outcome?.review?.decision === "approve" ? "已完成" : "进行中"; } -export function taskUpdateActionKey(outcome, previousResponse = null, retryBucket = Math.floor(Date.now() / (10 * 60 * 1000))) { +export function taskUpdateActionKey(outcome, previousResponse = null, retryBucket = Math.floor(Date.now() / (10 * 60 * 1000)), reconciliationId = "") { const baseKey = `task-pr:${outcome.repository}#${outcome.number}:${outcome.headSha || "closed"}:${outcomeStateKey(outcome)}`; + if (reconciliationId) return `${baseKey}:reconcile:${reconciliationId}`; if (previousResponse?.matched === false && previousResponse.reason === "task_not_linked") return `${baseKey}:link-retry:${retryBucket}`; return baseKey; } -async function taskUpdateActionKeyForEvent(env, outcome) { +async function taskUpdateActionKeyForEvent(env, outcome, reconciliationId = "") { + if (reconciliationId) return taskUpdateActionKey(outcome, null, undefined, reconciliationId); const baseKey = taskUpdateActionKey(outcome); const existing = await env.STATE_DB.prepare("SELECT status, response_json FROM action_log WHERE action_key = ?").bind(baseKey).first(); const previousResponse = existing?.status === "completed" && existing.response_json ? JSON.parse(existing.response_json) : null; @@ -342,7 +344,7 @@ async function processGithubClosed(env, event) { const reference = pullRequestReference(event); const payload = eventPayload(event); if (!reference || payload.pull_request?.merged !== true) return { handled: false, reason: "pull_request_not_merged" }; - return { handled: true, repository: reference.repository, number: reference.number, url: `https://github.com/${reference.repository}/pull/${reference.number}`, title: payload.pull_request.title || `PR #${reference.number}`, author_login: payload.pull_request.user?.login || null, headSha: payload.pull_request.merge_commit_sha || payload.pull_request.head?.sha || null, review: { summary: "GitHub 已确认该 PR 合并。", decision: "approve", blocking_findings: [], requested_changes: [], non_blocking_findings: [], test_evidence: [] }, merge: { merged: true, sha: payload.pull_request.merge_commit_sha || null } }; + return { handled: true, repository: reference.repository, number: reference.number, url: `https://github.com/${reference.repository}/pull/${reference.number}`, title: payload.pull_request.title || `PR #${reference.number}`, author_login: payload.pull_request.user?.login || null, headRefName: payload.pull_request.head?.ref || null, headSha: payload.pull_request.merge_commit_sha || payload.pull_request.head?.sha || null, task_sync: payload.task_sync === true, review: { summary: "GitHub 已确认该 PR 合并。", decision: "approve", blocking_findings: [], requested_changes: [], non_blocking_findings: [], test_evidence: [] }, merge: { merged: true, sha: payload.pull_request.merge_commit_sha || null } }; } function pushRecord(event) { @@ -365,7 +367,8 @@ async function processGithubEffects(env, event, reviewResult) { if (event.event_type === "pull_request" && githubAction(event) === "closed") { const closed = await processGithubClosed(env, event); if (!closed.handled) return closed; - const task = await runAction(env, event, "task_update", await taskUpdateActionKeyForEvent(env, closed), () => applyGithubOutcome(env, closed)); + const reconciliationId = event.payload?.task_sync === true ? event.event_id : ""; + const task = await runAction(env, event, "task_update", await taskUpdateActionKeyForEvent(env, closed, reconciliationId), () => applyGithubOutcome(env, closed)); return { ...closed, task }; } if (!reviewResult) return null; @@ -503,6 +506,7 @@ async function processInternalReconcile(env, event) { repository: { full_name: repository }, pull_request: merged ? { ...fresh, merged: true } : fresh, force_review: event.payload.force_review === true, + task_sync: merged, }, }; if (merged) return processGithubEffects(env, synthetic, null); diff --git a/cloudflare/automation/src/online-runtime.js b/cloudflare/automation/src/online-runtime.js index a235d1d..d6a943f 100644 --- a/cloudflare/automation/src/online-runtime.js +++ b/cloudflare/automation/src/online-runtime.js @@ -4,8 +4,8 @@ import { ensureNextStageTask, ensureStageOneTask, listProjectRecords, + mergedPullRequestTaskSyncCandidate, projectForRepository, - shouldReconcileMergedPullRequest, taskRecordSnapshot, } from "./task-board.js"; import { formatDailyReport } from "./online.js"; @@ -51,7 +51,31 @@ export function mergedPullRequestSyncKey(repository, pullRequest, date = new Dat const number = Number(pullRequest?.number); const mergeId = String(pullRequest?.merge_commit_sha || pullRequest?.head?.sha || pullRequest?.merged_at || "").trim(); if (!Number.isInteger(number) || number < 1 || !mergeId) throw new Error("合并 PR 的编号或提交标识缺失"); - return `merged-pr-task-sync:${repository}#${number}:${mergeId}:${shanghaiDateKey(date)}`; + return `merged-pr-task-sync:v2:${repository}#${number}:${mergeId}:${shanghaiDateKey(date)}`; +} + +export function mergedPullRequestScanActionKey(projectKey, date = new Date()) { + return `online-merged-pr-scan:v2:${projectKey}:${shanghaiDateKey(date)}`; +} + +export function selectUnambiguousMergedPullRequestCandidates(candidates) { + const groups = new Map(); + for (const candidate of Array.isArray(candidates) ? candidates : []) { + if (!candidate?.record_id || !candidate?.pullRequest) continue; + const group = groups.get(candidate.record_id) || []; + group.push(candidate); + groups.set(candidate.record_id, group); + } + const selected = []; + for (const group of groups.values()) { + if (group.length === 1) { + selected.push(group[0]); + continue; + } + const explicitlyLinked = group.filter((candidate) => candidate.association !== "github_identity"); + if (explicitlyLinked.length === 1) selected.push(explicitlyLinked[0]); + } + return selected; } async function enqueueReviewForOpenPullRequest({ env, project, pullRequest, enqueueEvent, runAction, eventId }) { @@ -87,7 +111,7 @@ async function enqueueMergedPullRequestTaskSync({ env, project, pullRequest, enq source: "internal", event_type: "reconcile", delivery_id: `merged-pr:${project.repository}#${number}:${pullRequest.merge_commit_sha || pullRequest.head?.sha || pullRequest.merged_at}`, - payload: { repository: project.repository, number }, + payload: { repository: project.repository, number, task_sync: true }, }).then(() => ({ queued: true, repository: project.repository, number })), ); } @@ -163,7 +187,7 @@ async function continueCompletedTasks({ env, project, records, runAction, eventI export async function runOnlineReconciliation({ env, enqueueEvent, runAction } = {}) { if (typeof enqueueEvent !== "function" || typeof runAction !== "function") throw new Error("online reconciliation dependencies are missing"); const eventId = `cron-reconcile:${reconciliationBucket()}`; - const summary = { projects: 0, members: 0, stageOneTasks: 0, nextStageTasks: 0, nextStageSkipped: {}, messages: 0, reviewsQueued: 0, openPullRequests: 0, mergedPullRequests: 0, mergedTaskSyncQueued: 0 }; + const summary = { projects: 0, members: 0, stageOneTasks: 0, nextStageTasks: 0, nextStageSkipped: {}, messages: 0, reviewsQueued: 0, openPullRequests: 0, mergedPullRequests: 0, mergedTaskSyncQueued: 0, mergedTaskSyncAmbiguous: 0 }; for (const repository of PROJECT_REPOSITORIES) { const project = projectForRepository(env, repository); if (!project) throw new Error(`project is not configured: ${repository}`); @@ -190,25 +214,30 @@ export async function runOnlineReconciliation({ env, enqueueEvent, runAction } = if (result.queued) summary.reviewsQueued += 1; } const syncDate = new Date(); - const syncDay = shanghaiDateKey(syncDate); const mergedScan = await runAction( env, eventForCron(eventId), "online_merged_pr_scan", - `online-merged-pr-scan:${project.key}:${syncDay}`, + mergedPullRequestScanActionKey(project.key, syncDate), async () => { const mergedPullRequests = await listMergedPullRequests(env, project.repository); - let queued = 0; + const candidates = []; for (const pullRequest of mergedPullRequests) { - if (!await shouldReconcileMergedPullRequest(env, project, records, pullRequest)) continue; + const candidate = await mergedPullRequestTaskSyncCandidate(env, project, records, pullRequest); + if (candidate) candidates.push({ ...candidate, pullRequest }); + } + const selectedCandidates = selectUnambiguousMergedPullRequestCandidates(candidates); + let queued = 0; + for (const { pullRequest } of selectedCandidates) { const result = await enqueueMergedPullRequestTaskSync({ env, project, pullRequest, enqueueEvent, runAction, eventId, date: syncDate }); if (result.queued) queued += 1; } - return { mergedPullRequests: mergedPullRequests.length, queued }; + return { mergedPullRequests: mergedPullRequests.length, queued, ambiguous: candidates.length - selectedCandidates.length }; }, ); summary.mergedPullRequests += Number(mergedScan.mergedPullRequests || 0); summary.mergedTaskSyncQueued += Number(mergedScan.queued || 0); + summary.mergedTaskSyncAmbiguous += Number(mergedScan.ambiguous || 0); summary.projects += 1; } return summary; diff --git a/cloudflare/automation/src/task-board.js b/cloudflare/automation/src/task-board.js index 8e4ad95..093894e 100644 --- a/cloudflare/automation/src/task-board.js +++ b/cloudflare/automation/src/task-board.js @@ -182,6 +182,17 @@ export function selectTaskForGithubIdentity(records, assigneeOpenId) { return candidates.length === 1 ? candidates[0] : null; } +export function selectTaskForMergedPullRequestFallback(records, assigneeOpenId, outcome) { + const reference = [outcome?.headRefName, outcome?.title].filter(Boolean).join(" "); + const pullRequestStage = parsePullRequestStage(reference); + if (!pullRequestStage) return null; + const candidate = selectTaskForGithubIdentity(records, assigneeOpenId); + if (!candidate) return null; + return parseStageNumber(valueText(candidate.fields[FIELD_NAMES.title])) === pullRequestStage + ? candidate + : null; +} + export function memberTaskPlan(records, memberOpenId) { const memberRecords = (Array.isArray(records) ? records : []) .map((record) => ({ record, snapshot: taskRecordSnapshot(record) })) @@ -537,28 +548,35 @@ async function previouslyLinkedTaskRecord(env, project, records, outcome) { return selectPreviouslyLinkedTaskRecord(records, responses); } -async function findTaskRecordForOutcome(env, project, records, outcome, prUrl) { +async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage = false } = {}) { let record = selectLinkedTaskRecord(records, prUrl, outcome.number); let linkedAutomatically = false; + let association = record ? "task_link" : null; if (!record) { record = await previouslyLinkedTaskRecord(env, project, records, outcome); linkedAutomatically = Boolean(record); + if (record) association = "saved_action"; } const githubLogin = String(outcome.author_login || "").trim(); if (!record && githubLogin) { const identity = await lookupGithubMember(env, project.repository, githubLogin); - const candidate = identity ? selectTaskForGithubIdentity(records, identity.assignee_open_id) : null; + const candidate = identity + ? requireExplicitStage + ? selectTaskForMergedPullRequestFallback(records, identity.assignee_open_id, outcome) + : selectTaskForGithubIdentity(records, identity.assignee_open_id) + : null; if (candidate && pullRequestStageMatchesTask(valueText(candidate.fields[FIELD_NAMES.title]), outcome)) { record = candidate; linkedAutomatically = true; + association = "github_identity"; } } - return { record, linkedAutomatically }; + return { record, linkedAutomatically, association }; } -export async function shouldReconcileMergedPullRequest(env, project, records, pullRequest) { +export async function mergedPullRequestTaskSyncCandidate(env, project, records, pullRequest) { const number = Number(pullRequest?.number); - if (!Number.isInteger(number) || number < 1) return false; + if (!Number.isInteger(number) || number < 1) return null; const prUrl = `https://github.com/${project.repository}/pull/${number}`; const outcome = { number, @@ -566,11 +584,11 @@ export async function shouldReconcileMergedPullRequest(env, project, records, pu headRefName: pullRequest.head?.ref || "", title: pullRequest.title || "", }; - const { record } = await findTaskRecordForOutcome(env, project, records, outcome, prUrl); - if (!record) return false; + const { record, association } = await findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage: true }); + if (!record) return null; const snapshot = taskRecordSnapshot(record); - if (snapshot.status !== "已完成") return true; - return nextStageEligibility(records, snapshot).eligible; + if (snapshot.status === "已完成" && !nextStageEligibility(records, snapshot).eligible) return null; + return { record_id: record.record_id, association }; } function outcomeText(outcome) { @@ -591,7 +609,14 @@ export async function applyGithubOutcome(env, outcome) { const records = await listRecords(env, project); const prUrl = `https://github.com/${project.repository}/pull/${Number(outcome.number)}`; const githubLogin = String(outcome.author_login || "").trim(); - const { record, linkedAutomatically } = await findTaskRecordForOutcome(env, project, records, outcome, prUrl); + const { record, linkedAutomatically } = await findTaskRecordForOutcome( + env, + project, + records, + outcome, + prUrl, + { requireExplicitStage: outcome.task_sync === true }, + ); if (!record) return { matched: false, reason: "task_not_linked", project: project.key }; const assignee = assigneeId(record); const assigneeName = valueText(record.fields[FIELD_NAMES.assignee]) || "成员"; diff --git a/cloudflare/automation/test/online-routing.test.js b/cloudflare/automation/test/online-routing.test.js index 442999b..f289cab 100644 --- a/cloudflare/automation/test/online-routing.test.js +++ b/cloudflare/automation/test/online-routing.test.js @@ -1,6 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mergedPullRequestSyncKey } from "../src/online-runtime.js"; +import { + mergedPullRequestSyncKey, + mergedPullRequestScanActionKey, + selectUnambiguousMergedPullRequestCandidates, +} from "../src/online-runtime.js"; import { extractPullRequestReference, actionCanBeReclaimed, @@ -15,15 +19,45 @@ test("合并 PR 的任务同步标识在同一个中国日期内保持稳定", ( const pullRequest = { number: 28, merge_commit_sha: "merge-sha" }; assert.equal( mergedPullRequestSyncKey("XiaoCow666/CodeSense", pullRequest, new Date("2026-09-24T00:00:00Z")), - "merged-pr-task-sync:XiaoCow666/CodeSense#28:merge-sha:2026-09-24", + "merged-pr-task-sync:v2:XiaoCow666/CodeSense#28:merge-sha:2026-09-24", ); assert.equal( mergedPullRequestSyncKey("XiaoCow666/CodeSense", pullRequest, new Date("2026-09-24T15:59:59Z")), - "merged-pr-task-sync:XiaoCow666/CodeSense#28:merge-sha:2026-09-24", + "merged-pr-task-sync:v2:XiaoCow666/CodeSense#28:merge-sha:2026-09-24", ); assert.equal( mergedPullRequestSyncKey("XiaoCow666/CodeSense", pullRequest, new Date("2026-09-24T16:00:00Z")), - "merged-pr-task-sync:XiaoCow666/CodeSense#28:merge-sha:2026-09-25", + "merged-pr-task-sync:v2:XiaoCow666/CodeSense#28:merge-sha:2026-09-25", + ); +}); + +test("合并 PR 扫描标识可让修正版在当天重新运行", () => { + assert.equal( + mergedPullRequestScanActionKey("codesense", new Date("2026-09-24T00:00:00Z")), + "online-merged-pr-scan:v2:codesense:2026-09-24", + ); +}); + +test("同一任务有多个历史 PR 时只保留唯一的明确关联", () => { + const direct = { record_id: "rec_stage_2", association: "task_link", pullRequest: { number: 28 } }; + const inferred = { record_id: "rec_stage_2", association: "github_identity", pullRequest: { number: 29 } }; + assert.deepEqual( + selectUnambiguousMergedPullRequestCandidates([direct, inferred]), + [direct], + ); + assert.deepEqual( + selectUnambiguousMergedPullRequestCandidates([ + inferred, + { ...inferred, pullRequest: { number: 30 } }, + ]), + [], + ); + assert.deepEqual( + selectUnambiguousMergedPullRequestCandidates([ + direct, + { ...direct, pullRequest: { number: 31 } }, + ]), + [], ); }); diff --git a/cloudflare/automation/test/task-routing.test.js b/cloudflare/automation/test/task-routing.test.js index 19afcac..0790b36 100644 --- a/cloudflare/automation/test/task-routing.test.js +++ b/cloudflare/automation/test/task-routing.test.js @@ -13,7 +13,8 @@ import { selectTaskForGithubIdentity, ensureNextStageTask, selectPreviouslyLinkedTaskRecord, - shouldReconcileMergedPullRequest, + selectTaskForMergedPullRequestFallback, + mergedPullRequestTaskSyncCandidate, taskRecordSnapshot, } from "../src/task-board.js"; @@ -32,6 +33,32 @@ test("PR 链接被清空后仍可按已保存的任务关联找回记录", () => ); }); +test("历史合并 PR 只有明确阶段与任务阶段相同才允许按作者找回任务", () => { + const stageTwo = { + record_id: "rec_stage_2", + fields: { + "任务名称": "阶段二:修复问题(张诗若)", + "状态": ["进行中"], + "负责人": [{ id: "ou_member", name: "张诗若" }], + "任务模式": ["阶段任务"], + "GitHub PR / Issue": null, + }, + }; + const records = [stageTwo]; + assert.equal( + selectTaskForMergedPullRequestFallback(records, "ou_member", { headRefName: "codex/stage2-fix", title: "Stage 2 fix" }), + stageTwo, + ); + assert.equal( + selectTaskForMergedPullRequestFallback(records, "ou_member", { headRefName: "codex/bug-fix", title: "Improve validation" }), + null, + ); + assert.equal( + selectTaskForMergedPullRequestFallback(records, "ou_member", { headRefName: "codex/stage3-fix", title: "Stage 3 fix" }), + null, + ); +}); + test("合并 PR 只为未完成任务或缺少下一阶段的任务重新排队", async () => { const stageTwo = { record_id: "rec_stage_2", @@ -45,10 +72,16 @@ test("合并 PR 只为未完成任务或缺少下一阶段的任务重新排队" }; const project = { repository: "XiaoCow666/CodeSense" }; const pullRequest = { number: 28, merged: true, user: { login: "zhang" } }; - assert.equal(await shouldReconcileMergedPullRequest({}, project, [stageTwo], pullRequest), true); + assert.deepEqual(await mergedPullRequestTaskSyncCandidate({}, project, [stageTwo], pullRequest), { + record_id: "rec_stage_2", + association: "task_link", + }); const completedStageTwo = { ...stageTwo, fields: { ...stageTwo.fields, "状态": ["已完成"] } }; - assert.equal(await shouldReconcileMergedPullRequest({}, project, [completedStageTwo], pullRequest), true); + assert.deepEqual(await mergedPullRequestTaskSyncCandidate({}, project, [completedStageTwo], pullRequest), { + record_id: "rec_stage_2", + association: "task_link", + }); const stageThree = { record_id: "rec_stage_3", fields: { @@ -59,7 +92,7 @@ test("合并 PR 只为未完成任务或缺少下一阶段的任务重新排队" "GitHub PR / Issue": null, }, }; - assert.equal(await shouldReconcileMergedPullRequest({}, project, [completedStageTwo, stageThree], pullRequest), false); + assert.equal(await mergedPullRequestTaskSyncCandidate({}, project, [completedStageTwo, stageThree], pullRequest), null); }); test("stage parsing supports Arabic and Chinese stage names", () => { diff --git a/cloudflare/automation/test/task-update-routing.test.js b/cloudflare/automation/test/task-update-routing.test.js index 62b0d14..2153043 100644 --- a/cloudflare/automation/test/task-update-routing.test.js +++ b/cloudflare/automation/test/task-update-routing.test.js @@ -31,3 +31,19 @@ test("an earlier unlinked task update gets a bounded retry key", () => { "task-pr:XiaoCow666/CodeSense#61:abc123:approve:pull_request_not_mergeable:link-retry:100", ); }); + +test("merged PR reconciliation uses a fresh task update key", () => { + const outcome = { + repository: "XiaoCow666/CodeSense", + number: 28, + headSha: "merge-sha", + merged: true, + }; + const normalKey = taskUpdateActionKey(outcome); + const reconciliationKey = taskUpdateActionKey(outcome, null, undefined, "merged-pr-task-sync:v2:2026-09-24"); + assert.notEqual(reconciliationKey, normalKey); + assert.equal( + reconciliationKey, + `${normalKey}:reconcile:merged-pr-task-sync:v2:2026-09-24`, + ); +}); From d53ae9a1bd5e9ed8b2ce1fdd550a4fca14197e52 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Thu, 24 Sep 2026 15:53:12 +0800 Subject: [PATCH 2/4] fix: guard task stage reconciliation against mismatched PRs --- cloudflare/automation/src/task-board.js | 75 ++++++++++------ .../automation/test/task-routing.test.js | 90 +++++++++++++++++++ 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/cloudflare/automation/src/task-board.js b/cloudflare/automation/src/task-board.js index 093894e..ed05e4a 100644 --- a/cloudflare/automation/src/task-board.js +++ b/cloudflare/automation/src/task-board.js @@ -183,12 +183,11 @@ export function selectTaskForGithubIdentity(records, assigneeOpenId) { } export function selectTaskForMergedPullRequestFallback(records, assigneeOpenId, outcome) { - const reference = [outcome?.headRefName, outcome?.title].filter(Boolean).join(" "); - const pullRequestStage = parsePullRequestStage(reference); - if (!pullRequestStage) return null; + const { stage, conflict } = pullRequestStageEvidence(outcome); + if (conflict || !stage) return null; const candidate = selectTaskForGithubIdentity(records, assigneeOpenId); if (!candidate) return null; - return parseStageNumber(valueText(candidate.fields[FIELD_NAMES.title])) === pullRequestStage + return parseStageNumber(valueText(candidate.fields[FIELD_NAMES.title])) === stage ? candidate : null; } @@ -244,14 +243,30 @@ export function parseStageNumber(title) { return digits[chinese[1]] ?? null; } +function pullRequestStageValues(value) { + const pattern = /(?:阶段\s*(\d+|[零一二三四五六七八九十百]+)|\bstage[\s_-]*(\d+))/gi; + const stages = [...String(value || "").matchAll(pattern)] + .map((match) => match[2] ? Number(match[2]) : parseStageNumber(`阶段${match[1]}`)) + .filter((stage) => Number.isInteger(stage) && stage > 0); + return [...new Set(stages)]; +} + export function parsePullRequestStage(value) { - const match = String(value || "").match(/(?:阶段|stage)[\s_-]*(\d+)/i); - return match ? Number(match[1]) : null; + const stages = pullRequestStageValues(value); + return stages.length === 1 ? stages[0] : null; +} + +function pullRequestStageEvidence(outcome) { + const sources = [outcome?.headRefName, outcome?.title].map(pullRequestStageValues); + const stages = [...new Set(sources.flat())]; + const conflict = sources.some((values) => values.length > 1) || stages.length > 1; + return { stage: !conflict && stages.length === 1 ? stages[0] : null, conflict }; } export function pullRequestStageMatchesTask(taskTitle, outcome) { const taskStage = parseStageNumber(taskTitle); - const pullRequestStage = parsePullRequestStage([outcome?.headRefName, outcome?.title].filter(Boolean).join(" ")); + const { stage: pullRequestStage, conflict } = pullRequestStageEvidence(outcome); + if (conflict) return false; return !pullRequestStage || !taskStage || pullRequestStage === taskStage; } @@ -519,13 +534,12 @@ export async function ensureNextStageTask(env, project, record, records = []) { } function selectLinkedTaskRecord(records, prUrl, number) { - return (Array.isArray(records) ? records : []) - .filter((record) => prMatches(record, prUrl, number)) - .sort((left, right) => { - const leftStage = parseStageNumber(valueText(left.fields?.[FIELD_NAMES.title])) || Number.MAX_SAFE_INTEGER; - const rightStage = parseStageNumber(valueText(right.fields?.[FIELD_NAMES.title])) || Number.MAX_SAFE_INTEGER; - return leftStage - rightStage || String(left.record_id).localeCompare(String(right.record_id)); - })[0] || null; + const matches = (Array.isArray(records) ? records : []) + .filter((record) => prMatches(record, prUrl, number)); + return { + record: matches.length === 1 ? matches[0] : null, + ambiguous: matches.length > 1, + }; } export function selectPreviouslyLinkedTaskRecord(records, responses) { @@ -539,21 +553,29 @@ export function selectPreviouslyLinkedTaskRecord(records, responses) { async function previouslyLinkedTaskRecord(env, project, records, outcome) { const number = Number(outcome?.number); - if (!env.STATE_DB || !Number.isInteger(number) || number < 1) return null; - const prefix = `task-pr:${project.repository}#${number}:%`; + if (!env.STATE_DB || !Number.isInteger(number) || number < 1) return { record: null, ambiguous: false }; + const prefix = `task-pr:${project.repository}#${number}:`; const result = await env.STATE_DB.prepare( - "SELECT response_json FROM action_log WHERE action_type = 'task_update' AND status = 'completed' AND action_key LIKE ? ORDER BY updated_at DESC LIMIT 100", - ).bind(prefix).all(); - const responses = (result.results || []).map((row) => JSON.parse(row.response_json || "{}")); - return selectPreviouslyLinkedTaskRecord(records, responses); + "SELECT DISTINCT json_extract(response_json, '$.record_id') AS record_id FROM action_log WHERE action_key >= ? AND action_key < ? AND action_type = 'task_update' AND status = 'completed' AND response_json IS NOT NULL AND json_extract(response_json, '$.matched') = 1 AND json_extract(response_json, '$.record_id') IS NOT NULL LIMIT 2", + ).bind(prefix, `${prefix}\uffff`).all(); + const recordIds = (result.results || []).map((row) => String(row.record_id)); + if (recordIds.length > 1) return { record: null, ambiguous: true }; + if (!recordIds.length) return { record: null, ambiguous: false }; + const record = (Array.isArray(records) ? records : []) + .find((item) => item?.record_id === recordIds[0]) || null; + return { record, ambiguous: !record }; } async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage = false } = {}) { - let record = selectLinkedTaskRecord(records, prUrl, outcome.number); + const linked = selectLinkedTaskRecord(records, prUrl, outcome.number); + if (linked.ambiguous) return { record: null, linkedAutomatically: false, association: null, ambiguous: true }; + let record = linked.record; let linkedAutomatically = false; let association = record ? "task_link" : null; if (!record) { - record = await previouslyLinkedTaskRecord(env, project, records, outcome); + const previous = await previouslyLinkedTaskRecord(env, project, records, outcome); + if (previous.ambiguous) return { record: null, linkedAutomatically: false, association: null, ambiguous: true }; + record = previous.record; linkedAutomatically = Boolean(record); if (record) association = "saved_action"; } @@ -571,7 +593,10 @@ async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { association = "github_identity"; } } - return { record, linkedAutomatically, association }; + if (record && !pullRequestStageMatchesTask(valueText(record.fields[FIELD_NAMES.title]), outcome)) { + return { record: null, linkedAutomatically: false, association: null, ambiguous: false, stageConflict: true }; + } + return { record, linkedAutomatically, association, ambiguous: false, stageConflict: false }; } export async function mergedPullRequestTaskSyncCandidate(env, project, records, pullRequest) { @@ -609,7 +634,7 @@ export async function applyGithubOutcome(env, outcome) { const records = await listRecords(env, project); const prUrl = `https://github.com/${project.repository}/pull/${Number(outcome.number)}`; const githubLogin = String(outcome.author_login || "").trim(); - const { record, linkedAutomatically } = await findTaskRecordForOutcome( + const { record, linkedAutomatically, ambiguous, stageConflict } = await findTaskRecordForOutcome( env, project, records, @@ -617,7 +642,7 @@ export async function applyGithubOutcome(env, outcome) { prUrl, { requireExplicitStage: outcome.task_sync === true }, ); - if (!record) return { matched: false, reason: "task_not_linked", project: project.key }; + if (!record) return { matched: false, reason: stageConflict ? "task_stage_conflict" : ambiguous ? "task_association_ambiguous" : "task_not_linked", project: project.key }; const assignee = assigneeId(record); const assigneeName = valueText(record.fields[FIELD_NAMES.assignee]) || "成员"; if (githubLogin && assignee) await rememberGithubMember(env, project.repository, githubLogin, assignee, assigneeName); diff --git a/cloudflare/automation/test/task-routing.test.js b/cloudflare/automation/test/task-routing.test.js index 0790b36..6b62b95 100644 --- a/cloudflare/automation/test/task-routing.test.js +++ b/cloudflare/automation/test/task-routing.test.js @@ -33,6 +33,21 @@ test("PR 链接被清空后仍可按已保存的任务关联找回记录", () => ); }); +test("一百条重复历史后仍能发现另一条任务关联", () => { + const responses = Array.from({ length: 100 }, () => ({ matched: true, record_id: "rec_stage_2" })); + responses.push({ matched: true, record_id: "rec_stage_3" }); + assert.equal( + selectPreviouslyLinkedTaskRecord( + [ + { record_id: "rec_stage_2", fields: {} }, + { record_id: "rec_stage_3", fields: {} }, + ], + responses, + ), + null, + ); +}); + test("历史合并 PR 只有明确阶段与任务阶段相同才允许按作者找回任务", () => { const stageTwo = { record_id: "rec_stage_2", @@ -59,6 +74,81 @@ test("历史合并 PR 只有明确阶段与任务阶段相同才允许按作者 ); }); +test("PR 标题与分支标明不同阶段时不按作者关联任务", () => { + const stageThree = { + record_id: "rec_stage_3", + fields: { + "任务名称": "阶段三:追到根因(张诗若)", + "状态": ["进行中"], + "负责人": [{ id: "ou_member", name: "张诗若" }], + "任务模式": ["阶段任务"], + "GitHub PR / Issue": null, + }, + }; + assert.equal( + selectTaskForMergedPullRequestFallback([stageThree], "ou_member", { + headRefName: "codex/stage3-root-cause", + title: "Stage 2: root cause", + }), + null, + ); +}); + +test("同一 PR 关联多条任务记录时不选择其中一条", async () => { + const stageTwo = { + record_id: "rec_stage_2", + fields: { + "任务名称": "阶段二:修复问题(张诗若)", + "状态": ["待评审"], + "负责人": [{ id: "ou_member", name: "张诗若" }], + "任务模式": ["阶段任务"], + "GitHub PR / Issue": "https://github.com/XiaoCow666/CodeSense/pull/28", + }, + }; + const stageThree = { + ...stageTwo, + record_id: "rec_stage_3", + fields: { ...stageTwo.fields, "任务名称": "阶段三:追到根因(张诗若)" }, + }; + assert.equal( + await mergedPullRequestTaskSyncCandidate( + {}, + { repository: "XiaoCow666/CodeSense" }, + [stageTwo, stageThree], + { number: 28, user: { login: "zhang" }, merged: true }, + ), + null, + ); +}); + +test("明确关联的 PR 阶段与分支或标题冲突时不推进任务", async () => { + const stageThree = { + record_id: "rec_stage_3", + fields: { + "任务名称": "阶段三:追到根因(张诗若)", + "状态": ["待评审"], + "负责人": [{ id: "ou_member", name: "张诗若" }], + "任务模式": ["阶段任务"], + "GitHub PR / Issue": "https://github.com/XiaoCow666/CodeSense/pull/28", + }, + }; + assert.equal( + await mergedPullRequestTaskSyncCandidate( + {}, + { repository: "XiaoCow666/CodeSense" }, + [stageThree], + { + number: 28, + title: "Stage 2: root cause", + head: { ref: "codex/stage3-root-cause" }, + user: { login: "zhang" }, + merged: true, + }, + ), + null, + ); +}); + test("合并 PR 只为未完成任务或缺少下一阶段的任务重新排队", async () => { const stageTwo = { record_id: "rec_stage_2", From 4575ba466f7602728b0af4c4fffc5f8546f81bfb Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Thu, 24 Sep 2026 16:02:28 +0800 Subject: [PATCH 3/4] fix: restrict merged PR recovery to saved task links --- cloudflare/automation/src/task-board.js | 14 +++++++++----- cloudflare/automation/test/task-routing.test.js | 8 ++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cloudflare/automation/src/task-board.js b/cloudflare/automation/src/task-board.js index ed05e4a..b4c4635 100644 --- a/cloudflare/automation/src/task-board.js +++ b/cloudflare/automation/src/task-board.js @@ -542,6 +542,10 @@ function selectLinkedTaskRecord(records, prUrl, number) { }; } +export function isSafeMergedPullRequestAssociation(association) { + return association === "task_link" || association === "saved_action"; +} + export function selectPreviouslyLinkedTaskRecord(records, responses) { const recordIds = [...new Set((Array.isArray(responses) ? responses : []) .filter((response) => response?.matched === true && response.record_id) @@ -566,7 +570,7 @@ async function previouslyLinkedTaskRecord(env, project, records, outcome) { return { record, ambiguous: !record }; } -async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage = false } = {}) { +async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage = false, allowGithubIdentityFallback = true } = {}) { const linked = selectLinkedTaskRecord(records, prUrl, outcome.number); if (linked.ambiguous) return { record: null, linkedAutomatically: false, association: null, ambiguous: true }; let record = linked.record; @@ -580,7 +584,7 @@ async function findTaskRecordForOutcome(env, project, records, outcome, prUrl, { if (record) association = "saved_action"; } const githubLogin = String(outcome.author_login || "").trim(); - if (!record && githubLogin) { + if (!record && githubLogin && allowGithubIdentityFallback) { const identity = await lookupGithubMember(env, project.repository, githubLogin); const candidate = identity ? requireExplicitStage @@ -609,8 +613,8 @@ export async function mergedPullRequestTaskSyncCandidate(env, project, records, headRefName: pullRequest.head?.ref || "", title: pullRequest.title || "", }; - const { record, association } = await findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage: true }); - if (!record) return null; + const { record, association } = await findTaskRecordForOutcome(env, project, records, outcome, prUrl, { requireExplicitStage: true, allowGithubIdentityFallback: false }); + if (!record || !isSafeMergedPullRequestAssociation(association)) return null; const snapshot = taskRecordSnapshot(record); if (snapshot.status === "已完成" && !nextStageEligibility(records, snapshot).eligible) return null; return { record_id: record.record_id, association }; @@ -640,7 +644,7 @@ export async function applyGithubOutcome(env, outcome) { records, outcome, prUrl, - { requireExplicitStage: outcome.task_sync === true }, + { requireExplicitStage: outcome.task_sync === true, allowGithubIdentityFallback: outcome.task_sync !== true }, ); if (!record) return { matched: false, reason: stageConflict ? "task_stage_conflict" : ambiguous ? "task_association_ambiguous" : "task_not_linked", project: project.key }; const assignee = assigneeId(record); diff --git a/cloudflare/automation/test/task-routing.test.js b/cloudflare/automation/test/task-routing.test.js index 6b62b95..b789042 100644 --- a/cloudflare/automation/test/task-routing.test.js +++ b/cloudflare/automation/test/task-routing.test.js @@ -15,9 +15,17 @@ import { selectPreviouslyLinkedTaskRecord, selectTaskForMergedPullRequestFallback, mergedPullRequestTaskSyncCandidate, + isSafeMergedPullRequestAssociation, taskRecordSnapshot, } from "../src/task-board.js"; +test("历史补漏仅接受任务直连或已保存动作的 PR 关联", () => { + assert.equal(isSafeMergedPullRequestAssociation("task_link"), true); + assert.equal(isSafeMergedPullRequestAssociation("saved_action"), true); + assert.equal(isSafeMergedPullRequestAssociation("github_identity"), false); + assert.equal(isSafeMergedPullRequestAssociation(null), false); +}); + test("PR 链接被清空后仍可按已保存的任务关联找回记录", () => { const record = { record_id: "rec_stage_2", fields: { "GitHub PR / Issue": null } }; assert.equal( From 9bd37b2671138493558e0fef86b25204913bfc1f Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Thu, 24 Sep 2026 16:08:33 +0800 Subject: [PATCH 4/4] fix: make saved PR lookup case insensitive --- .../automation/migrations/0004_action_log_task_lookup.sql | 2 ++ cloudflare/automation/src/task-board.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 cloudflare/automation/migrations/0004_action_log_task_lookup.sql diff --git a/cloudflare/automation/migrations/0004_action_log_task_lookup.sql b/cloudflare/automation/migrations/0004_action_log_task_lookup.sql new file mode 100644 index 0000000..0e13f44 --- /dev/null +++ b/cloudflare/automation/migrations/0004_action_log_task_lookup.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_action_log_task_lookup + ON action_log(action_type, status, action_key COLLATE NOCASE); diff --git a/cloudflare/automation/src/task-board.js b/cloudflare/automation/src/task-board.js index b4c4635..691577b 100644 --- a/cloudflare/automation/src/task-board.js +++ b/cloudflare/automation/src/task-board.js @@ -560,7 +560,7 @@ async function previouslyLinkedTaskRecord(env, project, records, outcome) { if (!env.STATE_DB || !Number.isInteger(number) || number < 1) return { record: null, ambiguous: false }; const prefix = `task-pr:${project.repository}#${number}:`; const result = await env.STATE_DB.prepare( - "SELECT DISTINCT json_extract(response_json, '$.record_id') AS record_id FROM action_log WHERE action_key >= ? AND action_key < ? AND action_type = 'task_update' AND status = 'completed' AND response_json IS NOT NULL AND json_extract(response_json, '$.matched') = 1 AND json_extract(response_json, '$.record_id') IS NOT NULL LIMIT 2", + "SELECT DISTINCT json_extract(response_json, '$.record_id') AS record_id FROM action_log WHERE action_type = 'task_update' AND status = 'completed' AND action_key COLLATE NOCASE >= ? AND action_key COLLATE NOCASE < ? AND response_json IS NOT NULL AND json_extract(response_json, '$.matched') = 1 AND json_extract(response_json, '$.record_id') IS NOT NULL LIMIT 2", ).bind(prefix, `${prefix}\uffff`).all(); const recordIds = (result.results || []).map((row) => String(row.record_id)); if (recordIds.length > 1) return { record: null, ambiguous: true };