diff --git a/.github/workflows/github-slack-notifications.yml b/.github/workflows/github-slack-notifications.yml new file mode 100644 index 00000000..31e069b3 --- /dev/null +++ b/.github/workflows/github-slack-notifications.yml @@ -0,0 +1,220 @@ +name: GitHub Slack Notifications + +# Central GitHub -> Slack integration point. Two triggers feed one shared +# Slack workflow (via SLACK_WEBHOOK_URL), which branches on event_type: +# - issues opened -> notify oncall of a new issue +# - comments on closed PRs -> redirect the commenter to open an issue, +# and notify oncall (closed-PR comments are +# otherwise easy to miss) +# Every payload sends the same key set so the Slack workflow can branch +# reliably; fields that don't apply to an event are sent empty. + +on: + issues: + types: [opened] + issue_comment: + types: [created] + +jobs: + notify-issue-opened: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Send issue details to Slack + # Attacker-controlled fields are passed through env: rather than + # interpolated into the YAML payload, to prevent workflow injection. + # For issue_opened, the issue_* fields carry the data and the + # pr_*/comment_* fields are empty. + env: + REPOSITORY: ${{ github.repository }} + CREATED_AT: ${{ github.event.issue.created_at }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_BODY: ${{ github.event.issue.body }} + LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }} + uses: slackapi/slack-github-action@v3.0.1 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + event_type: "issue_opened" + repository: "${{ env.REPOSITORY }}" + created_at: "${{ env.CREATED_AT }}" + issue_number: "${{ env.ISSUE_NUMBER }}" + issue_title: ${{ toJSON(env.ISSUE_TITLE) }} + issue_url: "${{ env.ISSUE_URL }}" + issue_author: "${{ env.ISSUE_AUTHOR }}" + issue_body: ${{ toJSON(env.ISSUE_BODY) }} + labels: ${{ toJSON(env.LABELS) }} + pr_number: "" + pr_title: "" + pr_url: "" + pr_author: "" + pr_state: "" + pr_closed_at: "" + pr_merged_at: "" + comment_id: "" + comment_url: "" + comment_author: "" + comment_body: "" + + closed-pr-comment-redirect: + # Only fire on comments left on PRs (issue_comment fires for issues too) + # that are already closed, and skip comments left by bots. + if: >- + github.event_name == 'issue_comment' && github.event.issue.pull_request != null && github.event.issue.state == + 'closed' && github.event.comment.user.type != 'Bot' + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: read + # Serialize per-PR so the marker-comment dedup is race-free (otherwise two + # rapid-fire comments could both see "no marker" and both post a redirect). + # cancel-in-progress is false so the second run still executes after the + # first finishes -- we want to evaluate dedup against the just-posted marker. + concurrency: + group: closed-pr-comment-${{ github.event.issue.number }} + cancel-in-progress: false + steps: + - name: Check commenter permission + id: perm + uses: actions/github-script@v9 + with: + script: | + // External users on private repos can 404 here; treat any + // failure as "not a maintainer" so the redirect still fires. + let permission = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + permission = data.permission; + } catch (err) { + core.info(`Permission lookup failed for ${context.payload.comment.user.login}: ${err.message}. Treating as non-maintainer.`); + } + const skip = ['admin', 'maintain', 'write'].includes(permission); + core.setOutput('skip', String(skip)); + core.info(`Commenter ${context.payload.comment.user.login} permission=${permission} skip=${skip}`); + + - name: Check for existing redirect comment + id: existing + if: steps.perm.outputs.skip != 'true' + uses: actions/github-script@v9 + with: + script: | + // Marker we embed in our reply so we don't double-post on the same PR. + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + per_page: 100, + } + ); + const alreadyPosted = comments.some(c => c.body && c.body.includes(marker)); + core.setOutput('already_posted', String(alreadyPosted)); + + - name: Post redirect comment + if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' + # The Slack notification is the load-bearing part of this job. If + # posting the bot reply fails (rate limit, transient error), don't + # block the Slack notification. + continue-on-error: true + uses: actions/github-script@v9 + env: + # Repos with issue templates use /issues/new/choose; repos without + # templates should change this to /issues/new. + ISSUES_NEW_URL: https://github.com/${{ github.repository }}/issues/new/choose + with: + script: | + const commenter = context.payload.comment.user.login; + const issuesNewUrl = process.env.ISSUES_NEW_URL; + const body = [ + '', + '', + `Thanks for the report, @${commenter} — feedback like this is exactly`, + "how we catch the things we missed. Because this PR is already", + "closed, the team won't see follow-up comments here.", + '', + 'Would you mind opening a new issue so we can track it properly?', + issuesNewUrl, + '', + 'If this is a security issue, please report it privately via', + 'https://aws.amazon.com/security/vulnerability-reporting/ instead', + 'of a public issue.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body, + }); + + - name: Compute PR state + id: pr_state + if: steps.perm.outputs.skip != 'true' + uses: actions/github-script@v9 + with: + script: | + // PRs surface as `issue` events; merged_at is null when closed-not-merged. + const mergedAt = context.payload.issue.pull_request && + context.payload.issue.pull_request.merged_at; + core.setOutput('state', mergedAt ? 'merged' : 'closed'); + + - name: Notify Slack + # Notify oncall only on the FIRST external comment per PR (gated by + # already_posted). Subsequent comments on the same PR don't notify -- + # the redirect comment has already directed the commenter to open an + # issue, and issues notify oncall via the issue path. This bounds + # notification volume regardless of how chatty a thread becomes. + if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' + # Attacker-controlled fields are passed through env: rather than + # interpolated into the YAML payload, to prevent workflow injection. + # For closed-PR comments, the issue_* fields are empty (this isn't + # an issue) and the pr_*/comment_* fields carry the real data. + env: + REPOSITORY: ${{ github.repository }} + CREATED_AT: ${{ github.event.comment.created_at }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_TITLE: ${{ github.event.issue.title }} + PR_URL: ${{ github.event.issue.html_url }} + PR_AUTHOR: ${{ github.event.issue.user.login }} + PR_CLOSED_AT: ${{ github.event.issue.closed_at }} + PR_MERGED_AT: ${{ github.event.issue.pull_request.merged_at }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_URL: ${{ github.event.comment.html_url }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_BODY: ${{ github.event.comment.body }} + uses: slackapi/slack-github-action@v3.0.1 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + event_type: "closed_pr_comment" + repository: "${{ env.REPOSITORY }}" + created_at: "${{ env.CREATED_AT }}" + issue_number: "" + issue_title: "" + issue_url: "" + issue_author: "" + issue_body: "" + labels: "" + pr_number: "${{ env.PR_NUMBER }}" + pr_title: ${{ toJSON(env.PR_TITLE) }} + pr_url: "${{ env.PR_URL }}" + pr_author: "${{ env.PR_AUTHOR }}" + pr_state: "${{ steps.pr_state.outputs.state }}" + pr_closed_at: "${{ env.PR_CLOSED_AT }}" + pr_merged_at: "${{ env.PR_MERGED_AT }}" + comment_id: "${{ env.COMMENT_ID }}" + comment_url: "${{ env.COMMENT_URL }}" + comment_author: "${{ env.COMMENT_AUTHOR }}" + comment_body: ${{ toJSON(env.COMMENT_BODY) }} diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml deleted file mode 100644 index 4f2b7659..00000000 --- a/.github/workflows/slack-issue-notification.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Slack Issue Notification - -on: - issues: - types: [opened] - -jobs: - notify-slack: - runs-on: ubuntu-latest - steps: - - name: Send issue details to Slack - uses: slackapi/slack-github-action@v3.0.1 - with: - webhook: ${{ secrets.SLACK_WEBHOOK_URL }} - webhook-type: webhook-trigger - payload: | - issue_title: "${{ github.event.issue.title }}" - issue_number: "${{ github.event.issue.number }}" - issue_url: "${{ github.event.issue.html_url }}" - issue_author: "${{ github.event.issue.user.login }}" - issue_body: ${{ toJSON(github.event.issue.body) }} - repository: "${{ github.repository }}" - created_at: "${{ github.event.issue.created_at }}"