|
| 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 }} |
0 commit comments