diff --git a/.github/workflows/repair-comment-router.yml b/.github/workflows/repair-comment-router.yml index 23f99e1104..8f92b64eca 100644 --- a/.github/workflows/repair-comment-router.yml +++ b/.github/workflows/repair-comment-router.yml @@ -1,6 +1,8 @@ name: repair comment router on: + repository_dispatch: + types: [clawsweeper_comment] workflow_dispatch: inputs: execute: @@ -33,6 +35,11 @@ on: required: false default: "" type: string + comment_ids: + description: "Optional comma-separated issue comment ids to route directly" + required: false + default: "" + type: string runner: description: "Runner label for repair planning/review work" required: true @@ -44,7 +51,7 @@ on: default: blacksmith-16vcpu-ubuntu-2404 type: string schedule: - - cron: "*/10 * * * *" + - cron: "*/5 * * * *" permissions: contents: write @@ -65,6 +72,15 @@ jobs: with: filter: blob:none fetch-depth: 0 + sparse-checkout: | + .github + jobs + results + src/repair + package.json + pnpm-lock.yaml + tsconfig.repair.json + sparse-checkout-cone-mode: false - name: Create GitHub App token id: app_token @@ -95,8 +111,17 @@ jobs: since="${{ github.event_name == 'workflow_dispatch' && inputs.since || '' }}" max_comments="${{ github.event_name == 'workflow_dispatch' && inputs.max_comments || vars.CLAWSWEEPER_COMMENT_MAX_COMMENTS || '100' }}" item_numbers="${{ github.event_name == 'workflow_dispatch' && inputs.item_numbers || '' }}" + comment_ids="${{ github.event_name == 'workflow_dispatch' && inputs.comment_ids || '' }}" runner="${{ github.event_name == 'workflow_dispatch' && inputs.runner || vars.CLAWSWEEPER_WORKER_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}" execution_runner="${{ github.event_name == 'workflow_dispatch' && inputs.execution_runner || vars.CLAWSWEEPER_EXECUTION_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }}" + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + target_repo="${{ github.event.client_payload.target_repo || 'openclaw/openclaw' }}" + lookback_minutes="${{ github.event.client_payload.lookback_minutes || vars.CLAWSWEEPER_COMMENT_LOOKBACK_MINUTES || '180' }}" + since="" + max_comments="${{ github.event.client_payload.max_comments || vars.CLAWSWEEPER_COMMENT_MAX_COMMENTS || '20' }}" + item_numbers="${{ github.event.client_payload.item_number || '' }}" + comment_ids="${{ github.event.client_payload.comment_id || '' }}" + fi args=( --write-report @@ -113,7 +138,11 @@ jobs: if [ -n "$item_numbers" ]; then args+=(--item-numbers "$item_numbers") fi + if [ -n "$comment_ids" ]; then + args+=(--comment-ids "$comment_ids") + fi if { [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.execute }}" = "true" ]; } || + { [ "${{ github.event_name }}" = "repository_dispatch" ]; } || { [ "${{ github.event_name }}" = "schedule" ] && [ "${{ vars.CLAWSWEEPER_COMMENT_ROUTER_EXECUTE || '0' }}" = "1" ]; }; then args+=(--execute) fi @@ -122,6 +151,10 @@ jobs: - name: Commit comment router ledger if: always() run: | + if [ -z "$(git status --porcelain -- results/comment-router.json jobs)" ]; then + echo "No durable comment-router ledger or job changes to publish." + exit 0 + fi pnpm run repair:publish-main -- \ --message "chore: record ClawSweeper comment routing" \ --path results/comment-router.json \ diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index ed8e9b769c..79ae9f8287 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -349,6 +349,10 @@ jobs: - name: Commit event comment router ledger if: always() run: | + if [ -z "$(git status --porcelain -- results/comment-router.json jobs)" ]; then + echo "No durable event comment-router ledger or job changes to publish." + exit 0 + fi pnpm run repair:publish-main -- \ --message "chore: record ClawSweeper event comment routing" \ --path results/comment-router.json \ diff --git a/docs/repair/operations.md b/docs/repair/operations.md index 640e100583..ae03b1d316 100644 --- a/docs/repair/operations.md +++ b/docs/repair/operations.md @@ -174,6 +174,10 @@ Live preflight hydrates job-provided refs by default and records linked refs wit ## Maintainer Comment Routing `pnpm run repair:comment-router` scans recent issue and PR comments in the target repo. +Target repositories can also forward matching `issue_comment` events as +`clawsweeper_comment` repository dispatches with the exact comment id. Those +comments get an immediate `eyes` reaction from the ClawSweeper app, and the +scheduled sweep remains a five-minute fallback. It accepts only maintainer-authored commands, gated by GitHub `author_association` values `OWNER`, `MEMBER`, or `COLLABORATOR` by default. Contributor comments are ignored without a reply. @@ -210,10 +214,13 @@ include the target PR head SHA. That lets edited ClawSweeper comments wake ClawSweeper again after the PR branch changes while unchanged comment versions remain idempotent. -Use `--item-number ` or `--item-numbers ` to route only specific -open issue or PR comments. The event review workflow uses this targeted path -after syncing its durable ClawSweeper verdict so automerge can act on a fresh -`pass` marker without waiting for the scheduled comment-router sweep. +Use `--comment-id `, `--comment-ids `, `--item-number `, or +`--item-numbers ` to route only specific comments or specific open issue +or PR comments. The event review workflow uses this targeted path after syncing +its durable ClawSweeper verdict so automerge can act on a fresh `pass` marker +without waiting for the scheduled comment-router sweep. No-op targeted +acknowledgements, such as already-processed commands or already-enabled +automerge commands, do not publish a durable ledger commit. If the adopted automerge worker returns no executable fix artifact, the executor posts one idempotent outcome comment on the opted-in PR. That status diff --git a/docs/target-dispatcher.md b/docs/target-dispatcher.md index 3a280bfe2f..b7361d6b6e 100644 --- a/docs/target-dispatcher.md +++ b/docs/target-dispatcher.md @@ -22,6 +22,8 @@ name: ClawSweeper Dispatch on: issues: types: [opened, reopened, edited, labeled, unlabeled] + issue_comment: + types: [created, edited] pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled] @@ -54,8 +56,22 @@ jobs: private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} owner: openclaw repositories: clawsweeper + permission-contents: write + + - name: Create target comment token + id: target_token + if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }} + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-issues: write + permission-pull-requests: read - name: Dispatch exact ClawSweeper review + if: ${{ github.event_name != 'issue_comment' }} env: GH_TOKEN: ${{ steps.token.outputs.token }} TARGET_REPO: ${{ github.repository }} @@ -79,9 +95,50 @@ jobs: gh api repos/openclaw/clawsweeper/dispatches \ --method POST \ --input - <<< "$payload" + + - name: Acknowledge and dispatch ClawSweeper comment + if: ${{ github.event_name == 'issue_comment' }} + env: + DISPATCH_TOKEN: ${{ steps.token.outputs.token }} + TARGET_TOKEN: ${{ steps.target_token.outputs.token }} + TARGET_REPO: ${{ github.repository }} + ITEM_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_BODY: ${{ github.event.comment.body }} + SOURCE_ACTION: ${{ github.event.action }} + run: | + if [ -z "$DISPATCH_TOKEN" ]; then + echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." + exit 0 + fi + body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt" + printf '%s\n' "$COMMENT_BODY" > "$body_file" + if ! grep -Eiq '(^|[[:space:]])@clawsweeper\b|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then + echo "No ClawSweeper command found in comment." + exit 0 + fi + if [ -n "$TARGET_TOKEN" ]; then + GH_TOKEN="$TARGET_TOKEN" gh api -X POST \ + -H "Accept: application/vnd.github+json" \ + "repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \ + -f content="eyes" >/dev/null || true + fi + payload="$(jq -nc \ + --arg target_repo "$TARGET_REPO" \ + --argjson item_number "$ITEM_NUMBER" \ + --argjson comment_id "$COMMENT_ID" \ + --arg source_event "issue_comment" \ + --arg source_action "$SOURCE_ACTION" \ + '{event_type:"clawsweeper_comment",client_payload:{target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action}}')" + GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \ + --method POST \ + --input - <<< "$payload" ``` -Comments are intentionally not a trigger. Bot-authored label churn is also +Comments are a lightweight trigger only when the body contains a ClawSweeper +command. The target workflow reacts with `eyes` immediately and dispatches +`clawsweeper_comment` to the comment router with the exact comment id, so it +does not need to wait for the scheduled sweep. Bot-authored label churn is also ignored. Human label changes are debounced and may run after an active dispatcher, but they must not cancel a content-changing dispatch before it posts to ClawSweeper. Content-changing events such as issue edits and PR synchronizes diff --git a/src/repair/comment-router-utils.ts b/src/repair/comment-router-utils.ts index c30f52bede..9932be3795 100644 --- a/src/repair/comment-router-utils.ts +++ b/src/repair/comment-router-utils.ts @@ -125,6 +125,7 @@ export function readLedger(file: JsonValue) { export function appendLedger(current: LooseRecord, entries: LooseRecord[]) { const compact = entries .filter((entry: JsonValue) => ["executed", "skipped"].includes(entry.status)) + .filter((entry: JsonValue) => !isNoopSkip(entry)) .map((entry: JsonValue) => { const actions = compactLedgerActions(entry.actions); return { @@ -161,12 +162,36 @@ export function appendLedger(current: LooseRecord, entries: LooseRecord[]) { ...(actions.length > 0 ? { actions } : {}), }; }); + if (compact.length === 0) return false; const byCommentVersion = new Map( (current.commands ?? []).map((entry: JsonValue) => [ledgerEntryKey(entry), entry]), ); - for (const entry of compact) byCommentVersion.set(ledgerEntryKey(entry), entry); + let changed = false; + for (const entry of compact) { + const key = ledgerEntryKey(entry); + const previous = byCommentVersion.get(key); + if (previous && stableLedgerEntry(previous) === stableLedgerEntry(entry)) continue; + byCommentVersion.set(key, entry); + changed = true; + } + if (!changed) return false; current.updated_at = new Date().toISOString(); current.commands = [...byCommentVersion.values()].slice(-1000); + return true; +} + +function isNoopSkip(entry: LooseRecord) { + if (String(entry.status ?? "") !== "skipped") return false; + const reason = String(entry.reason ?? ""); + return ( + reason === "comment version already processed in ledger" || + reason === "matching ClawSweeper response comment already exists" || + /already enabled for this PR/i.test(reason) + ); +} + +function stableLedgerEntry(entry: LooseRecord) { + return JSON.stringify({ ...entry, processed_at: null }); } function ledgerEntryKey(entry: LooseRecord) { diff --git a/src/repair/comment-router.ts b/src/repair/comment-router.ts index 03478cc0bf..175e2212ca 100644 --- a/src/repair/comment-router.ts +++ b/src/repair/comment-router.ts @@ -90,11 +90,14 @@ const { lookupConcurrency, since, itemNumbers, + commentIds, allowedAssociations, allowedRepositoryPermissions, trustedBots, } = config; +const startedAtMs = Date.now(); +const timings: LooseRecord[] = []; const ledger = readLedger(ledgerPath()); const TARGET_LOOKUP_RETRY_ATTEMPTS = 3; const processedCommentVersions = new Set( @@ -107,7 +110,7 @@ const plannedAutoRepairHeads = new Set(); const collaboratorPermissionCache = new Map(); const liveTargetCache = new Map(); const issueCommentsCache = new Map(); -const comments = listCandidateComments(); +const comments = measure("list_candidate_comments", () => listCandidateComments()); const rawCommands: LooseRecord[] = []; for (const comment of comments) { @@ -148,8 +151,10 @@ for (const comment of comments) { rawCommands.push(command); } -await prehydrateCommandLookups(rawCommands); -const commands = rawCommands.map((command) => classifyCommand(command)); +await measureAsync("prehydrate_command_lookups", () => prehydrateCommandLookups(rawCommands)); +const commands = measure("classify_commands", () => + rawCommands.map((command) => classifyCommand(command)), +); const actionable = commands.filter((command: JsonValue) => command.status === "ready"); const report: LooseRecord = { @@ -162,6 +167,7 @@ const report: LooseRecord = { execute, max_comments: maxComments, item_numbers: [...itemNumbers], + comment_ids: [...commentIds], max_autoclose_targets: maxAutocloseTargets, scanned_comments: comments.length, commands_seen: commands.length, @@ -175,34 +181,59 @@ const report: LooseRecord = { }; if (execute) { - assertMutationActorIsClawsweeperBot(); - for (const command of commands) acknowledgeSkippedMaintainerCommand(command); - const dispatchCount = actionable.filter((command: JsonValue) => - REPAIR_INTENTS.has(command.intent), - ).length; - if (dispatchCount > 0) { - report.live_worker_capacity_before_dispatch = waitForCapacity - ? waitForLiveWorkerCapacity({ - repo: repairRepo, - workflow, - requested: dispatchCount, - maxLiveWorkers, - }) - : assertLiveWorkerCapacity({ - repo: repairRepo, - workflow, - requested: dispatchCount, - maxLiveWorkers, - }); - } - for (const command of actionable) executeCommand(command); - appendLedger(ledger, commands); - writeLedger(ledgerPath(), ledger); + await measureAsync("execute_commands", async () => { + assertMutationActorIsClawsweeperBot(); + for (const command of commands) acknowledgeSkippedMaintainerCommand(command); + const dispatchCount = actionable.filter( + (command: JsonValue) => + REPAIR_INTENTS.has(command.intent) || MERGE_INTENTS.has(command.intent), + ).length; + if (dispatchCount > 0) { + report.live_worker_capacity_before_dispatch = waitForCapacity + ? waitForLiveWorkerCapacity({ + repo: repairRepo, + workflow, + requested: dispatchCount, + maxLiveWorkers, + }) + : assertLiveWorkerCapacity({ + repo: repairRepo, + workflow, + requested: dispatchCount, + maxLiveWorkers, + }); + } + for (const command of actionable) executeCommand(command); + }); + report.ledger_changed = measure("append_ledger", () => appendLedger(ledger, commands)); + if (report.ledger_changed) writeLedger(ledgerPath(), ledger); } +report.timings = { + total_ms: Date.now() - startedAtMs, + phases: timings, +}; if (writeReport) writeReportFile(repoRoot(), report); console.log(JSON.stringify(report, null, 2)); +function measure(name: string, fn: () => T): T { + const start = Date.now(); + try { + return fn(); + } finally { + timings.push({ name, ms: Date.now() - start }); + } +} + +async function measureAsync(name: string, fn: () => Promise): Promise { + const start = Date.now(); + try { + return await fn(); + } finally { + timings.push({ name, ms: Date.now() - start }); + } +} + function assertMutationActorIsClawsweeperBot() { try { const viewer = ghJson(["api", "user"]); @@ -1407,6 +1438,16 @@ function executeAutomerge(command: LooseRecord) { }; const block = validateAutomergeReadiness({ command, view, target: latestTarget }); if (block) { + if (isAutomergeChangelogBlock(block)) { + return { + action: "merge", + status: "repair_needed", + reason: block, + repair_reason: + "CHANGELOG.md entry is required before automerge; dispatch a focused changelog repair", + merge_method: "squash", + }; + } if (isTransientAutomergeBlock(block, view)) { return { action: "merge", status: "waiting", reason: block, merge_method: "squash" }; } @@ -1624,6 +1665,10 @@ function validateAutomergeReadiness({ command, view, target }: LooseRecord) { return ""; } +function isAutomergeChangelogBlock(reason: string) { + return /CHANGELOG\.md entry is required/i.test(String(reason ?? "")); +} + function isTransientAutomergeBlock(reason: string, view: LooseRecord) { const text = String(reason ?? "").toLowerCase(); if (text.includes("checks are not green")) return hasPendingChecks(view.statusCheckRollup ?? []); @@ -1713,6 +1758,13 @@ function listRecentComments() { } function listCandidateComments() { + if (commentIds.size > 0) { + return selectCommentsForRouting({ + recentComments: [...commentIds].map((commentId) => fetchIssueComment(commentId)), + durableComments: [], + maxComments, + }); + } if (itemNumbers.size > 0) { return selectCommentsForRouting({ recentComments: [...itemNumbers].flatMap((number) => issueCommentsFor(number)), @@ -1766,6 +1818,12 @@ function listRepairLoopReviewComments() { ); } +function fetchIssueComment(commentId: JsonValue) { + return ghJson(["api", `repos/${targetRepo}/issues/comments/${commentId}`], { + attempts: TARGET_LOOKUP_RETRY_ATTEMPTS, + }); +} + function listOpenIssueNumbersWithLabel(label: string) { return ghPaged( `repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`, diff --git a/src/repair/config.ts b/src/repair/config.ts index c40f0854be..4e4cc72505 100644 --- a/src/repair/config.ts +++ b/src/repair/config.ts @@ -35,6 +35,7 @@ export type CommentRouterConfig = { lookbackMinutes: number; since: string; itemNumbers: Set; + commentIds: Set; allowedAssociations: Set; allowedRepositoryPermissions: Set; trustedBots: Set; @@ -128,6 +129,12 @@ export function readCommentRouterConfig(args: LooseRecord): CommentRouterConfig .join(","), "item-numbers", ), + commentIds: numberSet( + [args["comment-id"], args["comment-ids"], process.env.CLAWSWEEPER_COMMENT_IDS] + .filter((value) => value !== undefined && value !== null) + .join(","), + "comment-ids", + ), allowedAssociations: upperCaseSet( process.env.CLAWSWEEPER_COMMENT_ALLOWED_ASSOCIATIONS ?? DEFAULT_ALLOWED_ASSOCIATIONS.join(","), diff --git a/test/repair/comment-router-utils.test.mjs b/test/repair/comment-router-utils.test.mjs index 50fd87b67f..e559e57acc 100644 --- a/test/repair/comment-router-utils.test.mjs +++ b/test/repair/comment-router-utils.test.mjs @@ -48,22 +48,70 @@ test("appendLedger keeps edited comment versions separate", () => { test("appendLedger leaves waiting commands retryable", () => { const ledger = { updated_at: null, commands: [] }; - appendLedger(ledger, [ - { - idempotency_key: "transient", - comment_id: "124", - comment_version_key: "124:2026-04-29T03:00:00Z", - comment_updated_at: "2026-04-29T03:00:00Z", - status: "waiting", - intent: "clawsweeper_re_review", - issue_number: 74499, - repo: "openclaw/openclaw", - }, - ]); + assert.equal( + appendLedger(ledger, [ + { + idempotency_key: "transient", + comment_id: "124", + comment_version_key: "124:2026-04-29T03:00:00Z", + comment_updated_at: "2026-04-29T03:00:00Z", + status: "waiting", + intent: "clawsweeper_re_review", + issue_number: 74499, + repo: "openclaw/openclaw", + }, + ]), + false, + ); assert.equal(ledger.commands.length, 0); }); +test("appendLedger ignores no-op skipped command versions", () => { + const ledger = { updated_at: null, commands: [] }; + + assert.equal( + appendLedger(ledger, [ + { + idempotency_key: "already-processed", + comment_id: "124", + comment_version_key: "124:2026-04-29T03:00:00Z", + comment_updated_at: "2026-04-29T03:00:00Z", + status: "skipped", + reason: "comment version already processed in ledger", + intent: "automerge", + issue_number: 74499, + repo: "openclaw/openclaw", + }, + ]), + false, + ); + + assert.equal(ledger.commands.length, 0); +}); + +test("appendLedger reports compact executed writes", () => { + const ledger = { updated_at: null, commands: [] }; + + assert.equal( + appendLedger(ledger, [ + { + idempotency_key: "processed", + comment_id: "125", + comment_version_key: "125:2026-04-29T03:01:00Z", + comment_updated_at: "2026-04-29T03:01:00Z", + status: "executed", + intent: "clawsweeper_re_review", + issue_number: 74499, + repo: "openclaw/openclaw", + }, + ]), + true, + ); + + assert.equal(ledger.commands.length, 1); +}); + test("appendLedger preserves compact executed actions for repair caps", () => { const ledger = { updated_at: null, commands: [] };