Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 173 additions & 116 deletions .github/workflows/clawsweeper-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,191 +12,248 @@ permissions:
contents: read

concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event_name }}-${{ github.event.comment.id || github.event.issue.number || github.event.pull_request.number || github.run_id }}-${{ endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.actor || 'dispatchable' }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}

jobs:
dispatch:
runs-on: ubuntu-latest
# id-token proves this repository, event, branch, and run to the queue.
# The job never checks out or executes pull request code.
permissions:
contents: read
id-token: write
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
steps:
- name: Debounce bursty metadata events
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20

- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
permission-contents: write

- name: Pre-filter ClawSweeper comment
id: comment_filter
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then
if [[ "$COMMENT_BODY" == *"<!-- clawsweeper-maintainer-opt-in:"* ]]; then
echo "is_command=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|re-review|rerun([[:space:]]+|-)?review|status|explain|fix|build|implement|create([[:space:]]+|-)?pr|fix([[:space:]]+|-)?issue|autofix|auto([[:space:]]+|-)?fix|automerge|auto([[:space:]]+|-)?merge|approve|stop|autoclose)\b' <<< "$COMMENT_BODY"; then
echo "is_command=true" >> "$GITHUB_OUTPUT"
else
echo "is_command=false" >> "$GITHUB_OUTPUT"
fi
- name: Create target comment token
id: target_token
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
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 }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send the PR base branch in the OIDC payload

When a pull request targets a non-default branch, its pull_request_target OIDC identity uses that base branch, while this payload always claims the repository default branch (GitHub documents that pull_request_target uses the base ref). The intake verifier therefore rejects the request, and the new 4xx path exits successfully without the former repository_dispatch fallback, leaving this workflow unable to deliver such PR events. Set TARGET_BRANCH from github.base_ref/github.event.pull_request.base.ref for PR events and retain the default for issue events.

Useful? React with 👍 / 👎.

ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
SOURCE_EVENT: ${{ github.event_name }}
SOURCE_ACTION: ${{ github.event.action }}
TARGET_DISPATCH_URL: https://clawsweeper.openclaw.ai/github/target-dispatch
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
fi
ingress_fingerprint="$(node <<'NODE'
set -euo pipefail
source_identity_json="$(node <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
const pullRequest = event.pull_request && typeof event.pull_request === "object"
? event.pull_request
: {};
const issue = event.issue && typeof event.issue === "object" ? event.issue : {};
const source = process.env.ITEM_KIND === "pull_request" ? pullRequest : issue;
const headSha = String(pullRequest.head?.sha || "").trim().toLowerCase();
const baseSha = String(pullRequest.base?.sha || "").trim().toLowerCase();
const updatedAt = String(pullRequest.updated_at || "").trim();
const queueClaim = {};
const result = { queue_claim: queueClaim };
const sourceUpdatedAt = String(source.updated_at || "").trim();
if (sourceUpdatedAt && Number.isFinite(Date.parse(sourceUpdatedAt))) {
queueClaim.source_updated_at = sourceUpdatedAt;
}
if (
typeof source.title === "string" &&
(source.body === null || typeof source.body === "string") &&
typeof source.locked === "boolean" &&
Array.isArray(source.labels)
) {
const labels = [
...new Set(
source.labels
.map((label) => String(label?.name || "").trim().toLowerCase())
.filter((label) => {
if (!label) return false;
if (label === "proof: override") return true;
if (/^(?:status|rating|proof|merge-risk|impact|issue-rating):/.test(label)) return false;
if (/^p[0-3]$/.test(label)) return false;
if ([
"maturity:stable",
"feature: ✨ showcase",
"good first issue",
"mantis: telegram-visible-proof",
"proof: telegram-e2e",
"triage: needs-real-behavior-proof",
"clawsweeper-recovery-stuck",
"no-stale",
"stale",
].includes(label)) return false;
return !label.startsWith("clawsweeper:") || [
"clawsweeper:autofix",
"clawsweeper:automerge",
"clawsweeper:human-review",
"clawsweeper:manual-only",
"clawsweeper:bulk-filed",
].includes(label);
}),
),
].sort();
queueClaim.source_content_revision = crypto
.createHash("sha256")
.update(
JSON.stringify({
version: 2,
title: source.title,
body: source.body || "",
locked: source.locked,
close_guard_labels: labels,
}),
)
.digest("hex");
}
if (process.env.ITEM_KIND === "pull_request") {
if (/^[0-9a-f]{40}$/.test(headSha)) queueClaim.source_head_sha = headSha;
if (/^[0-9a-f]{40}$/.test(baseSha)) queueClaim.source_base_sha = baseSha;
if (typeof pullRequest.draft === "boolean") {
queueClaim.source_is_draft = pullRequest.draft;
}
}
if (
process.env.ITEM_KIND !== "pull_request" ||
!/^[0-9a-f]{40}$/.test(headSha) ||
!updatedAt
) {
process.stdout.write("");
process.stdout.write(JSON.stringify(result));
} else {
process.stdout.write(
crypto
.createHash("sha256")
.update(
JSON.stringify({
version: 1,
target_repo: String(process.env.TARGET_REPO || "").toLowerCase(),
item_number: Number(process.env.ITEM_NUMBER),
action: String(process.env.SOURCE_ACTION || ""),
head_sha: headSha,
updated_at: updatedAt,
body: typeof pullRequest.body === "string" ? pullRequest.body : "",
label: String(event.label?.name || ""),
}),
)
.digest("hex"),
);
result.ingress_route = "target_dispatcher";
result.ingress_fingerprint = crypto
.createHash("sha256")
.update(
JSON.stringify({
version: 1,
target_repo: String(process.env.TARGET_REPO || "").toLowerCase(),
item_number: Number(process.env.ITEM_NUMBER),
action: String(process.env.SOURCE_ACTION || ""),
head_sha: headSha,
updated_at: updatedAt,
body: typeof pullRequest.body === "string" ? pullRequest.body : "",
label: String(event.label?.name || ""),
}),
)
.digest("hex");
process.stdout.write(JSON.stringify(result));
}
NODE
)"
payload="$(jq -nc \
client_payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--arg target_branch "$TARGET_BRANCH" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
--arg source_event "$SOURCE_EVENT" \
--arg source_action "$SOURCE_ACTION" \
--arg ingress_fingerprint "$ingress_fingerprint" \
--argjson source_identity "$source_identity_json" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:({target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress} + (if $ingress_fingerprint != "" then {ingress_route:"target_dispatcher",ingress_fingerprint:$ingress_fingerprint} else {} end))}')"
gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
- name: Acknowledge and dispatch ClawSweeper comment
'{target_repo:$target_repo,target_branch:$target_branch,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress} + $source_identity')"
# The App webhook remains an independent delivery route.
if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] &&
[ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] &&
oidc_token="$(curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
--header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
--get --data-urlencode "audience=$TARGET_DISPATCH_URL" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -er '.value | strings | select(length > 0)')" &&
[ -n "$oidc_token" ]; then
echo "::add-mask::$oidc_token"
direct_body="$RUNNER_TEMP/clawsweeper-direct-intake.json"
if direct_status="$(curl --silent --show-error --connect-timeout 5 --max-time 20 \
--request POST \
--header "authorization: Bearer $oidc_token" \
--header "content-type: application/json" \
--data "$client_payload" \
--output "$direct_body" \
--write-out '%{http_code}' \
"$TARGET_DISPATCH_URL")" &&
[[ "$direct_status" == 2[0-9][0-9] ]]; then
echo "Queued ClawSweeper intake directly: $(jq -c . "$direct_body" 2>/dev/null || echo '{}')"
exit 0
fi
# Only non-transient 4xx is a deliberate refusal; the App webhook covers it.
# Timeouts, throttling, and verifier/Worker failures must stay re-runnable.
if [[ "${direct_status:-}" == 4[0-9][0-9] && "$direct_status" != 408 && "$direct_status" != 429 ]]; then
echo "::warning::ClawSweeper intake declined this event (HTTP $direct_status); the App webhook route still applies."
exit 0
fi
echo "::error::ClawSweeper intake unavailable (HTTP ${direct_status:-none}); re-run this job after the Worker recovers."
else
echo "::error::ClawSweeper OIDC token unavailable; the dispatch job needs id-token: write."
fi
exit 1
- name: Dispatch ClawSweeper comment
if: >-
${{
github.event_name == 'issue_comment' &&
steps.comment_filter.outputs.is_command == 'true'
}}
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 }}
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
SOURCE_ACTION: ${{ github.event.action }}
TARGET_DISPATCH_URL: https://clawsweeper.openclaw.ai/github/target-dispatch
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-proof-nudge([[:space:]]|-->)' "$body_file"; then
echo "Ignoring ClawSweeper proof-nudge 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
status_comment_id=""
if [ -n "$TARGET_TOKEN" ]; then
case "$AUTHOR_ASSOCIATION" in
OWNER|MEMBER|COLLABORATOR)
status_body="$(printf '%s\n' \
"<!-- clawsweeper-command-ack:$COMMENT_ID -->" \
"🦞👀" \
"ClawSweeper picked this up." \
"" \
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
status_comment_id="$(jq -r '.id // empty' <<< "$status_response")"
else
cat "$status_err" >&2
echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one."
fi
rm -f "$status_err"
;;
esac
fi
payload="$(jq -nc \
set -euo pipefail
client_payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--argjson comment_id "$COMMENT_ID" \
--arg status_comment_id "$status_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,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
'{target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:"issue_comment",source_action:$source_action}')"
# The App webhook remains an independent delivery route.
if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] &&
[ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] &&
oidc_token="$(curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \
--header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
--get --data-urlencode "audience=$TARGET_DISPATCH_URL" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -er '.value | strings | select(length > 0)')" &&
[ -n "$oidc_token" ]; then
echo "::add-mask::$oidc_token"
direct_body="$RUNNER_TEMP/clawsweeper-direct-intake.json"
if direct_status="$(curl --silent --show-error --connect-timeout 5 --max-time 20 \
--request POST \
--header "authorization: Bearer $oidc_token" \
--header "content-type: application/json" \
--data "$client_payload" \
--output "$direct_body" \
--write-out '%{http_code}' \
"$TARGET_DISPATCH_URL")" &&
[[ "$direct_status" == 2[0-9][0-9] ]]; then
echo "Queued ClawSweeper intake directly: $(jq -c . "$direct_body" 2>/dev/null || echo '{}')"
exit 0
fi
# Only non-transient 4xx is a deliberate refusal; the App webhook covers it.
# Timeouts, throttling, and verifier/Worker failures must stay re-runnable.
if [[ "${direct_status:-}" == 4[0-9][0-9] && "$direct_status" != 408 && "$direct_status" != 429 ]]; then
echo "::warning::ClawSweeper intake declined this event (HTTP $direct_status); the App webhook route still applies."
exit 0
fi
echo "::error::ClawSweeper intake unavailable (HTTP ${direct_status:-none}); re-run this job after the Worker recovers."
else
echo "::error::ClawSweeper OIDC token unavailable; the dispatch job needs id-token: write."
fi
exit 1
Loading