|
| 1 | +name: PR → Slack summary |
| 2 | + |
| 3 | +# Reusable workflow: posts an AI-summarized Slack message for every PR |
| 4 | +# opened, marked ready-for-review, or merged. Skips drafts and fork PRs |
| 5 | +# (the latter because GitHub denies secrets to fork-triggered workflows). |
| 6 | +# |
| 7 | +# Called from each repo via: |
| 8 | +# |
| 9 | +# on: |
| 10 | +# pull_request: |
| 11 | +# types: [opened, ready_for_review, closed] |
| 12 | +# branches: [main] # add other long-lived branches as needed |
| 13 | +# |
| 14 | +# jobs: |
| 15 | +# notify: |
| 16 | +# uses: backendforth/.github/.github/workflows/pr-slack-summary.yml@main |
| 17 | +# secrets: inherit |
| 18 | +# |
| 19 | +# Required org secrets (Settings → Secrets and variables → Actions): |
| 20 | +# - SLACK_WEBHOOK_URL Incoming Webhook pointed at the target channel |
| 21 | +# - OPENROUTER_API_KEY from openrouter.ai/keys (set a credit cap there) |
| 22 | + |
| 23 | +on: |
| 24 | + workflow_call: |
| 25 | + secrets: |
| 26 | + SLACK_WEBHOOK_URL: |
| 27 | + required: true |
| 28 | + OPENROUTER_API_KEY: |
| 29 | + required: true |
| 30 | + inputs: |
| 31 | + model: |
| 32 | + type: string |
| 33 | + default: "google/gemini-3.1-flash-lite" |
| 34 | + description: "OpenRouter model slug" |
| 35 | + |
| 36 | +permissions: |
| 37 | + contents: read |
| 38 | + pull-requests: read |
| 39 | + |
| 40 | +jobs: |
| 41 | + notify: |
| 42 | + if: | |
| 43 | + github.event.pull_request.draft == false && |
| 44 | + github.event.pull_request.head.repo.full_name == github.repository && |
| 45 | + (github.event.action != 'closed' || github.event.pull_request.merged == true) |
| 46 | + runs-on: ubuntu-latest |
| 47 | + steps: |
| 48 | + - name: Collect PR context |
| 49 | + id: ctx |
| 50 | + env: |
| 51 | + GH_TOKEN: ${{ github.token }} |
| 52 | + GH_REPO: ${{ github.repository }} |
| 53 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 54 | + run: | |
| 55 | + set -euo pipefail |
| 56 | + gh pr view "$PR_NUMBER" --repo "$GH_REPO" \ |
| 57 | + --json title,body,labels,files,author,baseRefName,headRefName \ |
| 58 | + > pr.json |
| 59 | +
|
| 60 | + jq -n \ |
| 61 | + --slurpfile pr pr.json \ |
| 62 | + --arg repo "$GH_REPO" \ |
| 63 | + --arg number "$PR_NUMBER" \ |
| 64 | + '{ |
| 65 | + repo: $repo, |
| 66 | + number: ($number | tonumber), |
| 67 | + title: $pr[0].title, |
| 68 | + body: (($pr[0].body // "") | .[0:8000]), |
| 69 | + labels: [$pr[0].labels[].name], |
| 70 | + files: [$pr[0].files[] | {path, additions, deletions}], |
| 71 | + author: $pr[0].author.login, |
| 72 | + base: $pr[0].baseRefName, |
| 73 | + head: $pr[0].headRefName |
| 74 | + }' > context.json |
| 75 | +
|
| 76 | + if [[ "${{ github.event.action }}" == "closed" ]]; then |
| 77 | + { |
| 78 | + echo "event_type=merged" |
| 79 | + echo "header_emoji=✅" |
| 80 | + echo "header_text=Merged" |
| 81 | + } >> "$GITHUB_OUTPUT" |
| 82 | + else |
| 83 | + { |
| 84 | + echo "event_type=opened" |
| 85 | + echo "header_emoji=📥" |
| 86 | + echo "header_text=New PR" |
| 87 | + } >> "$GITHUB_OUTPUT" |
| 88 | + fi |
| 89 | +
|
| 90 | + - name: Generate AI summary via OpenRouter |
| 91 | + id: ai |
| 92 | + env: |
| 93 | + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} |
| 94 | + MODEL: ${{ inputs.model }} |
| 95 | + run: | |
| 96 | + set -euo pipefail |
| 97 | +
|
| 98 | + SYSTEM='You analyze a GitHub pull request and emit STRICT JSON with three fields: |
| 99 | +- summary: 2-3 sentences describing intent and main changes. Plain prose, no markdown. |
| 100 | +- breaking_changes: array of bullet strings; [] if none. |
| 101 | +- risk: "low" | "medium" | "high". |
| 102 | + |
| 103 | +For Dependabot PRs, scan the body for major version bumps (X.x → Y.0) and list each as a breaking change. |
| 104 | +For feature PRs, scan title and body for BREAKING CHANGE, feat!, fix!, and explicit breaking notes. |
| 105 | + |
| 106 | +Be concise and concrete; no fluff. Output ONLY the JSON object, no markdown, no commentary.' |
| 107 | + |
| 108 | + PAYLOAD=$(jq -n \ |
| 109 | + --arg system "$SYSTEM" \ |
| 110 | + --slurpfile ctx context.json \ |
| 111 | + --arg model "$MODEL" \ |
| 112 | + '{ |
| 113 | + model: $model, |
| 114 | + response_format: { type: "json_object" }, |
| 115 | + messages: [ |
| 116 | + { role: "system", content: $system }, |
| 117 | + { role: "user", content: ($ctx[0] | tostring) } |
| 118 | + ] |
| 119 | + }') |
| 120 | + |
| 121 | + # Retry up to 3 times on 5xx |
| 122 | + for attempt in 1 2 3; do |
| 123 | + HTTP_CODE=$(curl -sS -o response.json -w '%{http_code}' \ |
| 124 | + https://openrouter.ai/api/v1/chat/completions \ |
| 125 | + -H "Authorization: Bearer $OPENROUTER_API_KEY" \ |
| 126 | + -H "Content-Type: application/json" \ |
| 127 | + -H "HTTP-Referer: https://github.com/backendforth" \ |
| 128 | + -H "X-Title: PR Slack Summary" \ |
| 129 | + -d "$PAYLOAD") |
| 130 | + |
| 131 | + if [[ "$HTTP_CODE" == "200" ]]; then |
| 132 | + break |
| 133 | + fi |
| 134 | + |
| 135 | + echo "OpenRouter returned HTTP $HTTP_CODE (attempt $attempt)" |
| 136 | + cat response.json || true |
| 137 | + |
| 138 | + if [[ $attempt -lt 3 && "$HTTP_CODE" -ge 500 ]]; then |
| 139 | + sleep 5 |
| 140 | + continue |
| 141 | + fi |
| 142 | + |
| 143 | + echo "Aborting after attempt $attempt" |
| 144 | + exit 1 |
| 145 | + done |
| 146 | + |
| 147 | + CONTENT=$(jq -r '.choices[0].message.content' response.json) |
| 148 | + |
| 149 | + # Strip accidental markdown code fences (just in case) |
| 150 | + CONTENT=$(printf '%s' "$CONTENT" | sed -E 's/^```(json)?//; s/```$//') |
| 151 | + |
| 152 | + if printf '%s' "$CONTENT" | jq empty 2>/dev/null; then |
| 153 | + printf '%s' "$CONTENT" > ai.json |
| 154 | + else |
| 155 | + echo "AI response was not valid JSON; using fallback" |
| 156 | + jq -n --arg s "$CONTENT" \ |
| 157 | + '{summary: $s, breaking_changes: [], risk: "low"}' > ai.json |
| 158 | + fi |
| 159 | + |
| 160 | + # Defensive defaults if fields are missing |
| 161 | + jq '{ |
| 162 | + summary: (.summary // "No summary available."), |
| 163 | + breaking_changes: (.breaking_changes // []), |
| 164 | + risk: (.risk // "low") |
| 165 | + }' ai.json > ai.final.json |
| 166 | + mv ai.final.json ai.json |
| 167 | + |
| 168 | + - name: Build Slack Block Kit payload |
| 169 | + env: |
| 170 | + PR_TITLE: ${{ github.event.pull_request.title }} |
| 171 | + PR_URL: ${{ github.event.pull_request.html_url }} |
| 172 | + PR_AUTHOR: ${{ github.event.pull_request.user.login }} |
| 173 | + REPO_NAME: ${{ github.event.repository.name }} |
| 174 | + IS_MERGED: ${{ github.event.pull_request.merged }} |
| 175 | + HEADER_EMOJI: ${{ steps.ctx.outputs.header_emoji }} |
| 176 | + HEADER_TEXT: ${{ steps.ctx.outputs.header_text }} |
| 177 | + run: | |
| 178 | + set -euo pipefail |
| 179 | +
|
| 180 | + jq -n \ |
| 181 | + --arg header "$HEADER_EMOJI $HEADER_TEXT · $REPO_NAME" \ |
| 182 | + --arg title "$PR_TITLE" \ |
| 183 | + --arg url "$PR_URL" \ |
| 184 | + --arg author "$PR_AUTHOR" \ |
| 185 | + --arg is_merged "$IS_MERGED" \ |
| 186 | + --slurpfile ai ai.json \ |
| 187 | + ' |
| 188 | + ($ai[0]) as $ai | |
| 189 | + ($is_merged == "true") as $merged | |
| 190 | + { blocks: |
| 191 | + ([ |
| 192 | + { type: "header", text: { type: "plain_text", text: $header, emoji: true } }, |
| 193 | + { type: "section", text: { type: "mrkdwn", |
| 194 | + text: ("*<" + $url + "|" + $title + ">*\nby `" + $author + "`") } }, |
| 195 | + { type: "section", text: { type: "mrkdwn", |
| 196 | + text: ("*Zusammenfassung*\n" + $ai.summary) } } |
| 197 | + ] |
| 198 | + + (if ($ai.breaking_changes | length) > 0 then |
| 199 | + [{ type: "section", text: { type: "mrkdwn", |
| 200 | + text: ("⚠️ *Breaking changes*\n" + |
| 201 | + ([$ai.breaking_changes[] | "• " + .] | join("\n"))) } }] |
| 202 | + else [] end) |
| 203 | + + (if $merged then [] else |
| 204 | + [{ type: "actions", elements: [ |
| 205 | + { type: "button", |
| 206 | + text: { type: "plain_text", text: "Review PR →" }, |
| 207 | + url: $url, style: "primary" } |
| 208 | + ]}] |
| 209 | + end)) |
| 210 | + }' > slack.json |
| 211 | +
|
| 212 | + cat slack.json |
| 213 | +
|
| 214 | + - name: Post to Slack |
| 215 | + uses: slackapi/slack-github-action@v2 |
| 216 | + with: |
| 217 | + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} |
| 218 | + webhook-type: incoming-webhook |
| 219 | + payload-file-path: ./slack.json |
0 commit comments