Skip to content

Commit 9db2bbc

Browse files
authored
ci: alert Slack on external-contributor issues & PRs (#2542)
1 parent 6e0880a commit 9db2bbc

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
name: Alert on external contributions
2+
3+
# Posts to Slack when an EXTERNAL contributor (author is not OWNER/MEMBER/
4+
# COLLABORATOR) opens an issue or PR, and adds an `external` label for triage.
5+
#
6+
# Why post directly instead of via the GitHub->Slack app's label subscription:
7+
# that app only notifies on open/close/reopen and does NOT fire when a label is
8+
# added after open — so a label-then-subscribe approach never alerts. Posting
9+
# from the workflow itself is reliable and real-time.
10+
#
11+
# Drop-in per-repo workflow. Needs a SLACK_WEBHOOK_OSS_PRS secret — an org-level
12+
# secret scoped to your OSS repos covers all of them at once. Uses
13+
# pull_request_target so the token/secret are available for fork PRs; safe
14+
# because it never checks out the PR's code (reads event metadata only).
15+
16+
on:
17+
issues:
18+
types: [opened, reopened]
19+
pull_request_target:
20+
types: [opened, reopened, ready_for_review]
21+
22+
permissions:
23+
issues: write
24+
pull-requests: write
25+
26+
concurrency:
27+
group:
28+
external-contrib-${{ github.event.issue.number ||
29+
github.event.pull_request.number }}
30+
cancel-in-progress: false
31+
32+
jobs:
33+
alert:
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/github-script@v9
37+
env:
38+
SLACK_WEBHOOK_OSS_PRS: ${{ secrets.SLACK_WEBHOOK_OSS_PRS }}
39+
with:
40+
script: |
41+
const LABEL = 'external';
42+
const item = context.payload.issue ?? context.payload.pull_request;
43+
if (!item) return;
44+
45+
// Skip draft PRs — they re-trigger via ready_for_review. (Issues have no draft.)
46+
if (item.draft) { core.info(`Skipping draft PR #${item.number}`); return; }
47+
// Skip bots (Dependabot, etc.).
48+
if (item.user?.type === 'Bot') { core.info(`Skipping bot ${item.user?.login}`); return; }
49+
// External == author has no write association with the repo.
50+
const INTERNAL = ['OWNER', 'MEMBER', 'COLLABORATOR'];
51+
if (INTERNAL.includes(item.author_association)) {
52+
core.info(`#${item.number} by ${item.user?.login} is internal (${item.author_association}); skipping.`);
53+
return;
54+
}
55+
// The `external` label doubles as our "already alerted" marker, so a
56+
// reopen (label persists) won't post to Slack twice.
57+
if (item.labels?.some(l => l.name === LABEL)) { core.info(`#${item.number} already handled.`); return; }
58+
59+
const { owner, repo } = context.repo;
60+
61+
// Deliver the alert FIRST. We only mark the item handled (label it)
62+
// after a confirmed send, so an unset/failing webhook leaves it
63+
// unlabeled and a later event (reopen / ready_for_review) retries,
64+
// rather than silently swallowing the required alert.
65+
const webhook = process.env.SLACK_WEBHOOK_OSS_PRS;
66+
if (!webhook) {
67+
core.warning('SLACK_WEBHOOK_OSS_PRS not set — no alert sent and not labeled (will retry on next event).');
68+
return;
69+
}
70+
const kind = context.payload.pull_request ? 'PR' : 'issue';
71+
const text = `:sparkles: *New external ${kind}* in \`${owner}/${repo}\`\n`
72+
+ `<${item.html_url}|#${item.number}: ${item.title}>\n`
73+
+ `by \`${item.user.login}\` (${item.author_association})`;
74+
// JSON.stringify safely escapes the attacker-controlled title.
75+
let delivered = false;
76+
try {
77+
const res = await fetch(webhook, {
78+
method: 'POST', headers: { 'Content-Type': 'application/json' },
79+
body: JSON.stringify({ blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] }),
80+
});
81+
delivered = res.ok;
82+
if (!delivered) core.setFailed(`Slack post failed (${res.status}); leaving #${item.number} unlabeled to retry.`);
83+
} catch (e) {
84+
core.setFailed(`Slack post errored (${e.message}); leaving #${item.number} unlabeled to retry.`);
85+
}
86+
if (!delivered) return;
87+
88+
// Delivered — mark handled by labeling (race-safe). The label also
89+
// dedupes reopens via the guard above. A label failure here at worst
90+
// risks one duplicate alert on a future reopen, never a lost alert.
91+
try {
92+
await github.rest.issues.createLabel({
93+
owner, repo, name: LABEL, color: 'D4C5F9',
94+
description: 'Opened by an external contributor',
95+
});
96+
core.info(`Created '${LABEL}' label.`);
97+
} catch (e) {
98+
if (e.status !== 422) throw e; // 422 = already exists; anything else is real
99+
}
100+
await github.rest.issues.addLabels({ owner, repo, issue_number: item.number, labels: [LABEL] });
101+
core.info(`Alerted Slack and labeled #${item.number} (${item.author_association}).`);

0 commit comments

Comments
 (0)