|
| 1 | +name: ClawSweeper Dispatch |
| 2 | + |
| 3 | +on: |
| 4 | + issues: |
| 5 | + types: [opened, reopened, edited, labeled, unlabeled] |
| 6 | + issue_comment: |
| 7 | + types: [created, edited] |
| 8 | + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution |
| 9 | + types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled] |
| 10 | + |
| 11 | +permissions: |
| 12 | + contents: read |
| 13 | + |
| 14 | +concurrency: |
| 15 | + group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} |
| 16 | + cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }} |
| 17 | + |
| 18 | +jobs: |
| 19 | + dispatch: |
| 20 | + runs-on: ubuntu-latest |
| 21 | + if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }} |
| 22 | + env: |
| 23 | + HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }} |
| 24 | + CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093 |
| 25 | + SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }} |
| 26 | + steps: |
| 27 | + - name: Debounce bursty metadata events |
| 28 | + if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }} |
| 29 | + run: sleep 20 |
| 30 | + |
| 31 | + - name: Create ClawSweeper dispatch token |
| 32 | + id: token |
| 33 | + if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }} |
| 34 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 35 | + with: |
| 36 | + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} |
| 37 | + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} |
| 38 | + owner: openclaw |
| 39 | + repositories: clawsweeper |
| 40 | + permission-contents: write |
| 41 | + |
| 42 | + - name: Pre-filter ClawSweeper comment |
| 43 | + id: comment_filter |
| 44 | + if: ${{ github.event_name == 'issue_comment' }} |
| 45 | + env: |
| 46 | + COMMENT_BODY: ${{ github.event.comment.body }} |
| 47 | + run: | |
| 48 | + set -euo pipefail |
| 49 | + if grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|autoclose|auto([[:space:]]+|-)?merge)\b' <<< "$COMMENT_BODY"; then |
| 50 | + echo "is_command=true" >> "$GITHUB_OUTPUT" |
| 51 | + else |
| 52 | + echo "is_command=false" >> "$GITHUB_OUTPUT" |
| 53 | + fi |
| 54 | +
|
| 55 | + - name: Create target comment token |
| 56 | + id: target_token |
| 57 | + if: >- |
| 58 | + ${{ |
| 59 | + github.event_name == 'issue_comment' && |
| 60 | + steps.comment_filter.outputs.is_command == 'true' && |
| 61 | + env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' |
| 62 | + }} |
| 63 | + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 |
| 64 | + with: |
| 65 | + client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }} |
| 66 | + private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }} |
| 67 | + owner: ${{ github.repository_owner }} |
| 68 | + repositories: ${{ github.event.repository.name }} |
| 69 | + permission-issues: write |
| 70 | + permission-pull-requests: read |
| 71 | + |
| 72 | + - name: Dispatch exact ClawSweeper review |
| 73 | + if: ${{ github.event_name != 'issue_comment' }} |
| 74 | + env: |
| 75 | + GH_TOKEN: ${{ steps.token.outputs.token }} |
| 76 | + TARGET_REPO: ${{ github.repository }} |
| 77 | + ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} |
| 78 | + ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }} |
| 79 | + SOURCE_EVENT: ${{ github.event_name }} |
| 80 | + SOURCE_ACTION: ${{ github.event.action }} |
| 81 | + run: | |
| 82 | + if [ -z "$GH_TOKEN" ]; then |
| 83 | + echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." |
| 84 | + exit 0 |
| 85 | + fi |
| 86 | + ingress_fingerprint="$(node <<'NODE' |
| 87 | + const crypto = require("node:crypto"); |
| 88 | + const fs = require("node:fs"); |
| 89 | + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); |
| 90 | + const pullRequest = event.pull_request && typeof event.pull_request === "object" |
| 91 | + ? event.pull_request |
| 92 | + : {}; |
| 93 | + const headSha = String(pullRequest.head?.sha || "").trim().toLowerCase(); |
| 94 | + const updatedAt = String(pullRequest.updated_at || "").trim(); |
| 95 | + if ( |
| 96 | + process.env.ITEM_KIND !== "pull_request" || |
| 97 | + !/^[0-9a-f]{40}$/.test(headSha) || |
| 98 | + !updatedAt |
| 99 | + ) { |
| 100 | + process.stdout.write(""); |
| 101 | + } else { |
| 102 | + process.stdout.write( |
| 103 | + crypto |
| 104 | + .createHash("sha256") |
| 105 | + .update( |
| 106 | + JSON.stringify({ |
| 107 | + version: 1, |
| 108 | + target_repo: String(process.env.TARGET_REPO || "").toLowerCase(), |
| 109 | + item_number: Number(process.env.ITEM_NUMBER), |
| 110 | + action: String(process.env.SOURCE_ACTION || ""), |
| 111 | + head_sha: headSha, |
| 112 | + updated_at: updatedAt, |
| 113 | + body: typeof pullRequest.body === "string" ? pullRequest.body : "", |
| 114 | + label: String(event.label?.name || ""), |
| 115 | + }), |
| 116 | + ) |
| 117 | + .digest("hex"), |
| 118 | + ); |
| 119 | + } |
| 120 | + NODE |
| 121 | + )" |
| 122 | + payload="$(jq -nc \ |
| 123 | + --arg target_repo "$TARGET_REPO" \ |
| 124 | + --argjson item_number "$ITEM_NUMBER" \ |
| 125 | + --arg item_kind "$ITEM_KIND" \ |
| 126 | + --arg source_event "$SOURCE_EVENT" \ |
| 127 | + --arg source_action "$SOURCE_ACTION" \ |
| 128 | + --arg ingress_fingerprint "$ingress_fingerprint" \ |
| 129 | + --argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \ |
| 130 | + '{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))}')" |
| 131 | + gh api repos/openclaw/clawsweeper/dispatches \ |
| 132 | + --method POST \ |
| 133 | + --input - <<< "$payload" |
| 134 | +
|
| 135 | + - name: Acknowledge and dispatch ClawSweeper comment |
| 136 | + if: >- |
| 137 | + ${{ |
| 138 | + github.event_name == 'issue_comment' && |
| 139 | + steps.comment_filter.outputs.is_command == 'true' |
| 140 | + }} |
| 141 | + env: |
| 142 | + DISPATCH_TOKEN: ${{ steps.token.outputs.token }} |
| 143 | + TARGET_TOKEN: ${{ steps.target_token.outputs.token }} |
| 144 | + TARGET_REPO: ${{ github.repository }} |
| 145 | + ITEM_NUMBER: ${{ github.event.issue.number }} |
| 146 | + COMMENT_ID: ${{ github.event.comment.id }} |
| 147 | + COMMENT_BODY: ${{ github.event.comment.body }} |
| 148 | + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} |
| 149 | + SOURCE_ACTION: ${{ github.event.action }} |
| 150 | + run: | |
| 151 | + if [ -z "$DISPATCH_TOKEN" ]; then |
| 152 | + echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured." |
| 153 | + exit 0 |
| 154 | + fi |
| 155 | + body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt" |
| 156 | + printf '%s\n' "$COMMENT_BODY" > "$body_file" |
| 157 | + if grep -Eiq '<!--[[:space:]]*clawsweeper-proof-nudge([[:space:]]|-->)' "$body_file"; then |
| 158 | + echo "Ignoring ClawSweeper proof-nudge comment." |
| 159 | + exit 0 |
| 160 | + fi |
| 161 | + if [ -n "$TARGET_TOKEN" ]; then |
| 162 | + GH_TOKEN="$TARGET_TOKEN" gh api -X POST \ |
| 163 | + -H "Accept: application/vnd.github+json" \ |
| 164 | + "repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \ |
| 165 | + -f content="eyes" >/dev/null || true |
| 166 | + fi |
| 167 | + status_comment_id="" |
| 168 | + if [ -n "$TARGET_TOKEN" ]; then |
| 169 | + case "$AUTHOR_ASSOCIATION" in |
| 170 | + OWNER|MEMBER|COLLABORATOR) |
| 171 | + status_body="$(printf '%s\n' \ |
| 172 | + "<!-- clawsweeper-command-ack:$COMMENT_ID -->" \ |
| 173 | + "🦞👀" \ |
| 174 | + "ClawSweeper picked this up." \ |
| 175 | + "" \ |
| 176 | + "Command router queued. I will update this comment with the next step.")" |
| 177 | + status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')" |
| 178 | + status_err="$(mktemp)" |
| 179 | + if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \ |
| 180 | + "repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \ |
| 181 | + --method POST \ |
| 182 | + --input - <<< "$status_payload" 2>"$status_err")"; then |
| 183 | + status_comment_id="$(jq -r '.id // empty' <<< "$status_response")" |
| 184 | + else |
| 185 | + cat "$status_err" >&2 |
| 186 | + echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one." |
| 187 | + fi |
| 188 | + rm -f "$status_err" |
| 189 | + ;; |
| 190 | + esac |
| 191 | + fi |
| 192 | + payload="$(jq -nc \ |
| 193 | + --arg target_repo "$TARGET_REPO" \ |
| 194 | + --argjson item_number "$ITEM_NUMBER" \ |
| 195 | + --argjson comment_id "$COMMENT_ID" \ |
| 196 | + --arg status_comment_id "$status_comment_id" \ |
| 197 | + --arg source_event "issue_comment" \ |
| 198 | + --arg source_action "$SOURCE_ACTION" \ |
| 199 | + '{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))}')" |
| 200 | + GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \ |
| 201 | + --method POST \ |
| 202 | + --input - <<< "$payload" |
0 commit comments