open-design/.github/workflows/discord-resolved.yml

241 lines
11 KiB
YAML

# 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.repository == 'nexu-io/open-design' && 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