# Notify Discord #resolved when an issue is closed by a merged PR. # # Trigger logic: # - issues.closed fires whenever an issue is closed (manually, by PR, or as not-planned) # - We require state_reason == "completed" AND that the issue's most recent # `closed` timeline event has a commit_id belonging to a merged PR. # - Then we post a rich Discord embed with: issue title + body excerpt, issue # author, the PR that resolved it, and the merger. # # Why a workflow instead of a raw repo→Discord webhook? # GitHub's webhook can't tell Discord "this issue was closed *by a merged PR*" — # the issues.closed payload doesn't carry that linkage. We have to walk the # timeline ourselves, which a workflow does in <1s. # # Why only the `closed` + `commit_id` path (no cross-referenced fallback)? # `cross-referenced` events fire on plain mentions ("related to #123"), # so trusting them creates false positives — a manually closed issue mentioned # by an unrelated merged PR would post to #resolved. The closed-event linkage # is the only signal GitHub itself uses to display "closed by PR #N", so it's # the source of truth. We accept the rare miss (e.g. a PR that closed an issue # via the web UI rather than via "Fixes" keyword) in exchange for zero false # positives. name: Discord · resolved on: issues: types: [closed] # Read-only. Discord post goes via webhook URL (a secret), not GitHub auth. # - contents:read : required by repos.listPullRequestsAssociatedWithCommit # - issues:read : timeline + issue body # - pull-requests:read : PR metadata (merger, title) permissions: contents: read issues: read pull-requests: read jobs: notify: # state_reason "completed" excludes "not planned" closures. # We further require an actual merged-PR linkage in the script below. if: github.event.issue.state_reason == 'completed' runs-on: ubuntu-latest steps: - name: Find the merged PR that closed this issue id: find-pr uses: actions/github-script@v7 with: script: | const issue = context.payload.issue; const timeline = await github.paginate( github.rest.issues.listEventsForTimeline, { owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, per_page: 100, } ); // Walk events backwards. The most recent `closed` event with a // commit_id whose containing PR is merged is our resolver. // We deliberately ignore `cross-referenced` events: those fire on // plain mentions, not just closing-keyword links, and trusting // them produces false positives. See top-of-file comment. let resolvingPr = null; for (let i = timeline.length - 1; i >= 0; i--) { const ev = timeline[i]; if (ev.event !== 'closed' || !ev.commit_id) continue; try { const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: ev.commit_id, }); const merged = prs.find((p) => p.merged_at); if (merged) { resolvingPr = merged; break; } } catch (e) { core.warning( `listPullRequestsAssociatedWithCommit failed for ${ev.commit_id}: ${e.message}` ); } } if (!resolvingPr) { core.info( 'No merged PR found via closed-event linkage — skipping Discord post. ' + 'This is expected for manual closes or web-UI "close with comment" events.' ); core.setOutput('skip', 'true'); return; } // Truncate body for the embed (Discord embed description max ~4096, // but readable cards stay under ~400 chars). const rawBody = (issue.body || '').trim(); const bodyExcerpt = rawBody.length > 380 ? rawBody.slice(0, 380).trim() + '…' : rawBody || '_(no description)_'; core.setOutput('skip', 'false'); core.setOutput('issue_number', String(issue.number)); core.setOutput('issue_title', issue.title); core.setOutput('issue_url', issue.html_url); core.setOutput('issue_author', issue.user.login); core.setOutput('issue_author_url', issue.user.html_url); core.setOutput('issue_author_avatar', issue.user.avatar_url); core.setOutput('issue_body', bodyExcerpt); core.setOutput('pr_number', String(resolvingPr.number)); core.setOutput('pr_title', resolvingPr.title); core.setOutput('pr_url', resolvingPr.html_url); core.setOutput('pr_merger', resolvingPr.merged_by?.login || resolvingPr.user.login); core.setOutput('pr_merger_url', resolvingPr.merged_by?.html_url || resolvingPr.user.html_url); core.setOutput('repo_full_name', `${context.repo.owner}/${context.repo.repo}`); - name: Post embed to Discord if: steps.find-pr.outputs.skip == 'false' env: WEBHOOK: ${{ secrets.DISCORD_RESOLVED_WEBHOOK }} ISSUE_NUMBER: ${{ steps.find-pr.outputs.issue_number }} ISSUE_TITLE: ${{ steps.find-pr.outputs.issue_title }} ISSUE_URL: ${{ steps.find-pr.outputs.issue_url }} ISSUE_AUTHOR: ${{ steps.find-pr.outputs.issue_author }} ISSUE_AUTHOR_URL: ${{ steps.find-pr.outputs.issue_author_url }} ISSUE_AUTHOR_AVATAR: ${{ steps.find-pr.outputs.issue_author_avatar }} ISSUE_BODY: ${{ steps.find-pr.outputs.issue_body }} PR_NUMBER: ${{ steps.find-pr.outputs.pr_number }} PR_TITLE: ${{ steps.find-pr.outputs.pr_title }} PR_URL: ${{ steps.find-pr.outputs.pr_url }} PR_MERGER: ${{ steps.find-pr.outputs.pr_merger }} PR_MERGER_URL: ${{ steps.find-pr.outputs.pr_merger_url }} REPO_FULL_NAME: ${{ steps.find-pr.outputs.repo_full_name }} run: | set -euo pipefail # ── Webhook URL sanity check ──────────────────────────────────── # Refuse to post if the secret is missing or doesn't look like a # Discord webhook URL — guards against accidentally leaking issue # metadata to a misconfigured endpoint. if [ -z "${WEBHOOK:-}" ]; then echo "DISCORD_RESOLVED_WEBHOOK secret not configured — aborting." exit 1 fi case "$WEBHOOK" in https://discord.com/api/webhooks/*) ;; https://discordapp.com/api/webhooks/*) ;; *) echo "WEBHOOK does not look like a Discord webhook URL — aborting." echo "(Expected prefix: https://discord.com/api/webhooks/...)" exit 1 ;; esac # ── Build embed JSON via jq ───────────────────────────────────── # Color 0x2eb67d (3066993) — green for "resolved". # `allowed_mentions: { parse: [] }` disables @everyone/@here/role/user # mentions so a malicious or accidental issue title can't ping the # channel. payload=$(jq -n \ --arg title "✅ #${ISSUE_NUMBER}: ${ISSUE_TITLE}" \ --arg url "$ISSUE_URL" \ --arg desc "$ISSUE_BODY" \ --arg author_name "$ISSUE_AUTHOR" \ --arg author_url "$ISSUE_AUTHOR_URL" \ --arg author_icon "$ISSUE_AUTHOR_AVATAR" \ --arg pr_field "[#${PR_NUMBER} ${PR_TITLE}](${PR_URL})" \ --arg merger_field "[@${PR_MERGER}](${PR_MERGER_URL})" \ --arg footer "$REPO_FULL_NAME" \ '{ username: "Issue Resolver", avatar_url: "https://github.githubassets.com/images/modules/logos_page/Octocat.png", allowed_mentions: { parse: [] }, embeds: [ { title: $title, url: $url, description: $desc, color: 3066993, author: { name: ("Reported by @" + $author_name), url: $author_url, icon_url: $author_icon }, fields: [ { name: "Resolved by PR", value: $pr_field, inline: false }, { name: "Merged by", value: $merger_field, inline: true } ], footer: { text: $footer }, timestamp: now | todateiso8601 } ] }') # ── POST with bounded retry on 429 ────────────────────────────── # Discord rate-limits webhooks per-channel and may return 429 with a # `retry-after` header (seconds, integer or float). We retry up to 3 # times honouring that header, with a sane default if the header is # missing. attempts=3 for attempt in $(seq 1 "$attempts"); do : > /tmp/resp_body : > /tmp/resp_headers status=$(curl -sS -o /tmp/resp_body -D /tmp/resp_headers \ -w '%{http_code}' \ -H 'Content-Type: application/json' \ -X POST "$WEBHOOK" \ -d "$payload" || echo '000') if [ "$status" = "204" ]; then echo "Discord post OK (attempt $attempt)." exit 0 fi if [ "$status" = "429" ] && [ "$attempt" -lt "$attempts" ]; then # Header is case-insensitive; tr to lowercase for matching. retry_after=$(tr -d '\r' < /tmp/resp_headers \ | awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {print $2; exit}') # Floor to integer; default to 5s if header missing/unparseable. retry_after_int=$(printf '%.0f' "${retry_after:-5}" 2>/dev/null || echo 5) [ "$retry_after_int" -lt 1 ] && retry_after_int=1 [ "$retry_after_int" -gt 60 ] && retry_after_int=60 echo "Rate limited (HTTP 429), retrying in ${retry_after_int}s (attempt ${attempt}/${attempts})…" sleep "$retry_after_int" continue fi echo "Discord webhook returned HTTP $status (attempt ${attempt}/${attempts}):" cat /tmp/resp_body # Non-429 errors are not retryable. exit 1 done echo "Discord post failed after ${attempts} attempts (last status: ${status})." exit 1