Skip to content

Commit 15c4e3f

Browse files
CopilotsubatoiCopilotgithub-advanced-security[bot]Copilot
authored
Send daily non-employee comment digest to Slack (#64415)
Co-authored-by: subatoi <32935794+subatoi@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 2df7a4f commit 15c4e3f

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
name: Daily non-employee comment digest
2+
3+
# Include issue/PR conversation comments, not inline review comments.
4+
on:
5+
workflow_dispatch:
6+
schedule:
7+
- cron: '20 16 * * *'
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
notify:
14+
if: github.repository == 'github/docs'
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 15
17+
steps:
18+
- name: Check out repo
19+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
20+
with:
21+
persist-credentials: false
22+
23+
- name: Build daily digest
24+
id: digest
25+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
26+
with:
27+
github-token: ${{ secrets.DOCS_BOT_PAT_BASE }}
28+
script: |
29+
// Octokit request logs include author names, even for expected non-member 404s.
30+
github.log = {
31+
...github.log,
32+
debug: () => {},
33+
info: () => {},
34+
warn: () => {},
35+
error: () => {},
36+
};
37+
// Fixed schedule boundaries avoid gaps or overlaps when scheduled runs start late.
38+
const { data: run } = await github.rest.actions.getWorkflowRun({
39+
...context.repo,
40+
run_id: context.runId,
41+
}).catch((error) => {
42+
throw new Error(`Fetching workflow run failed (HTTP ${error.status || 'unknown'}).`);
43+
});
44+
const runCreatedAt = new Date(run.created_at);
45+
const until = new Date(runCreatedAt.getTime());
46+
until.setUTCHours(16, 20, 0, 0);
47+
if (until.getTime() > runCreatedAt.getTime()) {
48+
until.setUTCDate(until.getUTCDate() - 1);
49+
}
50+
const since = new Date(until.getTime() - 24 * 60 * 60 * 1000);
51+
core.info(`Building digest for ${since.toISOString()} to ${until.toISOString()}.`);
52+
53+
const authors = new Map();
54+
let page = 0;
55+
try {
56+
for await (const { data: comments } of github.paginate.iterator(
57+
github.rest.issues.listCommentsForRepo,
58+
{
59+
owner: 'github',
60+
repo: 'docs',
61+
since: since.toISOString(),
62+
sort: 'created',
63+
direction: 'asc',
64+
per_page: 100,
65+
},
66+
)) {
67+
core.info(`Fetched comments page ${++page} (${comments.length} comments).`);
68+
for (const comment of comments) {
69+
const createdAt = Date.parse(comment.created_at);
70+
if (!(createdAt >= since.getTime() && createdAt < until.getTime())) {
71+
continue;
72+
}
73+
const author = comment.user;
74+
if (!author) {
75+
core.warning('Skipping a comment whose author is unavailable.');
76+
continue;
77+
}
78+
const login = author.login.toLowerCase();
79+
if (author.type === 'Bot' || ['docs-bot', 'copilot'].includes(login)) {
80+
continue;
81+
}
82+
if (!authors.has(login)) {
83+
authors.set(login, { login: author.login, comments: [] });
84+
}
85+
authors.get(login).comments.push({
86+
url: comment.html_url,
87+
conversation: comment.issue_url,
88+
});
89+
}
90+
}
91+
} catch (error) {
92+
throw new Error(`Fetching digest comments failed (HTTP ${error.status || 'unknown'}).`);
93+
}
94+
95+
const nonEmployees = [];
96+
let checked = 0;
97+
for (const author of authors.values()) {
98+
core.info(`Checking employee membership (${++checked}/${authors.size}).`);
99+
try {
100+
await github.rest.teams.getMembershipForUserInOrg({
101+
org: 'github',
102+
team_slug: 'employees',
103+
username: author.login,
104+
});
105+
} catch (error) {
106+
if (error.status !== 404) {
107+
throw new Error(`Employee membership check failed (HTTP ${error.status || 'unknown'}).`);
108+
}
109+
nonEmployees.push(author);
110+
}
111+
}
112+
113+
const totalComments = nonEmployees.reduce((sum, author) => sum + author.comments.length, 0);
114+
core.info(`Found ${totalComments} comments by ${nonEmployees.length} non-employees.`);
115+
if (totalComments === 0) {
116+
return;
117+
}
118+
119+
let message = `Daily non-employee comment digest for github/docs\n` +
120+
`${since.toISOString()} to ${until.toISOString()} (UTC)\n` +
121+
`Non-employee users: ${nonEmployees.length}; comments: ${totalComments}\n` +
122+
'Comment bodies omitted; follow the links to read them on GitHub.';
123+
const shortened = '\n\n[Digest shortened to fit one Slack message; some authors or comment links are omitted.]';
124+
const maximumLength = 35000 - shortened.length;
125+
const lines = nonEmployees.flatMap((author) => [
126+
`\n\n@${author.login} (https://github.com/${author.login})\n` +
127+
`Comments: ${author.comments.length}; conversations: ` +
128+
`${new Set(author.comments.map((comment) => comment.conversation)).size}`,
129+
...author.comments.map((comment) => `\n ${comment.url}`),
130+
]);
131+
for (const line of lines) {
132+
if (message.length + line.length > maximumLength) {
133+
message += shortened;
134+
core.warning('Digest shortened to fit one Slack message.');
135+
break;
136+
}
137+
message += line;
138+
}
139+
for (const author of nonEmployees) {
140+
core.setSecret(author.login);
141+
}
142+
core.setOutput('slack_message', message);
143+
144+
- name: Notify the docs-open-source channel
145+
if: ${{ steps.digest.outputs.slack_message != '' }}
146+
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
147+
with:
148+
method: chat.postMessage
149+
token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
150+
errors: true
151+
# Slack channel ID for docs-open-source.
152+
payload: |
153+
channel: C017M6T23GA
154+
text: ${{ toJSON(steps.digest.outputs.slack_message) }}
155+
mrkdwn: false
156+
link_names: false
157+
unfurl_links: false
158+
unfurl_media: false
159+
160+
- uses: ./.github/actions/create-workflow-failure-issue
161+
id: create-failure-issue
162+
if: ${{ failure() }}
163+
with:
164+
token: ${{ secrets.DOCS_BOT_PAT_BASE }}
165+
166+
- name: Notify Slack about workflow failure
167+
if: ${{ failure() }}
168+
uses: ./.github/actions/slack-alert
169+
with:
170+
slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }}
171+
slack_channel_id: C017M6T23GA
172+
issue_url: ${{ steps.create-failure-issue.outputs.issue_url }}

‎src/workflows/tests/actions-workflows.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ const alertWorkflows = workflows.filter(({ data }) =>
107107
const dailyWorkflows = scheduledWorkflows
108108
// purge-fastly.yml soft-purges daily at 02:20 UTC, outside the standard 16:20 slot.
109109
.filter(({ filename }) => filename !== 'purge-fastly.yml')
110+
// The non-employee digest runs every day, including weekends.
111+
.filter(({ filename }) => filename !== 'notify-non-employee-comments.yml')
110112
.filter(({ data }) =>
111113
data.on.schedule!.find(({ cron }: { cron: string }) => /^20 \d{1,2} /.test(cron)),
112114
)

0 commit comments

Comments
 (0)