From 4ac6997a2bb71b96503a33b9605c12f9b46ac5bb Mon Sep 17 00:00:00 2001 From: jth-nw Date: Wed, 5 Aug 2026 11:33:38 -0500 Subject: [PATCH] slack notification update --- .github/workflows/label-pr.yml | 60 +++++++++++++++++ .github/workflows/slack-notify-issue.yml | 33 +++++++--- .github/workflows/slack-notify-pr.yml | 60 +++++++++++------ .github/workflows/sync-dev-to-main.yml | 82 +----------------------- scripts/resolve-labels-for-teams.mjs | 41 ++++++++++++ 5 files changed, 167 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/label-pr.yml create mode 100644 scripts/resolve-labels-for-teams.mjs diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml new file mode 100644 index 0000000000..f72baa393e --- /dev/null +++ b/.github/workflows/label-pr.yml @@ -0,0 +1,60 @@ +name: Label PR by CODEOWNERS + +# Applies product labels to a PR based on the CODEOWNERS team(s) that own +# its changed files. slack-notify-pr.yml triggers off the resulting +# `labeled` event instead of `opened`, so CODEOWNERS teams are always known +# by the time it notifies. + +on: + # pull_request_target (not pull_request) so this job has a write-scoped + # token even for fork PRs. SAFETY: this workflow must never check out or + # execute the PR's head content — only read PR metadata via the GitHub + # API (gh pr view) and run code from the pinned base ref below. + pull_request_target: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: label-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Always the base ref — see the SAFETY note above. Never point + # this at the PR head/merge ref. + ref: ${{ github.event.pull_request.base.sha }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + + - name: Resolve and apply CODEOWNERS labels + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FILES=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json files --jq '.files[].path') + mapfile -t FILE_ARRAY <<< "$FILES" + TEAMS=$(node scripts/resolve-codeowners-teams.mjs "${FILE_ARRAY[@]}") + LABELS=$(node scripts/resolve-labels-for-teams.mjs "$TEAMS") + + if [ -z "$LABELS" ]; then + echo "No matching CODEOWNERS labels for changed files — nothing to add" + exit 0 + fi + + IFS=',' read -ra LABEL_ARRAY <<< "$LABELS" + ARGS=() + for label in "${LABEL_ARRAY[@]}"; do + ARGS+=(--add-label "$label") + done + + gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository }} "${ARGS[@]}" diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml index d462b16d28..8cee3eb00c 100644 --- a/.github/workflows/slack-notify-issue.yml +++ b/.github/workflows/slack-notify-issue.yml @@ -1,11 +1,14 @@ name: Slack Notify Issue -# Posts a Slack message to #docs-gh whenever an Issue is created or commented on, -# @-mentioning the relevant CODEOWNERS team members. +# Posts a Slack message to #docs-gh once an Issue has been labeled, or is +# commented on, @-mentioning the relevant CODEOWNERS team members. +# Triggering on `labeled` (rather than `opened`) avoids racing the +# auto-labeler (claude-issue-labeler.yml), which assigns labels +# asynchronously — by the time an issue is labeled, its teams are known. on: issues: - types: [opened] + types: [labeled] issue_comment: types: [created] @@ -43,9 +46,20 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_TITLE: ${{ github.event.issue.title }} run: | - # Fetch current labels from the issue. Labels may not be applied yet - # (auto-labeling runs asynchronously), so an empty result here just - # means no team mention — it does not skip the notification itself. + # Only notify once per issue — on the first label landing. Later + # labels (manual or from a second auto-labeler pass) shouldn't + # re-announce "Issue created". + if [ "${{ github.event_name }}" = "issues" ]; then + LABEL_COUNT=$(jq '.issue.labels | length' "$GITHUB_EVENT_PATH") + if [ "$LABEL_COUNT" != "1" ]; then + echo "Not the first label on this issue — already notified" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + # Fetch current labels. May include more than just the one that + # triggered this run, if several landed in the same labeling pass. LABELS=$(gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') # Skip for KB Operations tracking issues (any kb/ label) @@ -68,11 +82,10 @@ jobs: # Collect matched teams (deduplicated), comma-joined TEAMS="" while IFS= read -r label; do + [ -z "$label" ] && continue team=$(echo "$MAPPING" | jq -r --arg l "$label" '.[$l] // empty') - if [ -n "$team" ]; then - if ! echo "$TEAMS" | grep -qF "$team"; then - TEAMS="${TEAMS:+$TEAMS,}$team" - fi + if [ -n "$team" ] && [[ ",$TEAMS," != *",$team,"* ]]; then + TEAMS="${TEAMS:+$TEAMS,}$team" fi done <<< "$LABELS" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml index 6e5385ec86..7f493d92ba 100644 --- a/.github/workflows/slack-notify-pr.yml +++ b/.github/workflows/slack-notify-pr.yml @@ -1,20 +1,23 @@ name: Slack notify PR activity -# Posts a Slack message to #docs-gh whenever a PR is created, has a review -# requested, or is commented on — @-mentioning the CODEOWNERS team(s) that -# own the changed files. Fork PRs also get a PR comment tagging codeowners, -# since they otherwise have no other reviewer visibility into who owns the -# changed area. +# Posts a Slack message to #docs-gh once a PR has been labeled (by +# label-pr.yml, based on CODEOWNERS), has a review requested, or is +# commented on — @-mentioning the CODEOWNERS team(s) that own the changed +# files. Fork PRs also get a PR comment tagging codeowners, since they +# otherwise have no other reviewer visibility into who owns the changed +# area. Triggering on `labeled` (rather than `opened`) avoids racing +# label-pr.yml — by the time a PR is labeled, CODEOWNERS teams are known. +# +# pull_request_target (not pull_request) so this job has repo secrets and +# a write-scoped token even for fork PRs. SAFETY: this workflow must never +# check out or execute the PR's head content — only read PR metadata via +# the GitHub API (gh pr view) and run code from the pinned base ref below. +# Adding a checkout of the fork's head ref here would reintroduce the +# exact secret-exfiltration risk pull_request_target is meant to avoid. on: - # pull_request_target (not pull_request) so this job has repo secrets and - # a write-scoped token even for fork PRs. SAFETY: this workflow must never - # check out or execute the PR's head content — only read PR metadata via - # the GitHub API (gh pr view) and run code from the pinned base ref below. - # Adding a checkout of the fork's head ref here would reintroduce the - # exact secret-exfiltration risk pull_request_target is meant to avoid. pull_request_target: - types: [opened, review_requested] + types: [labeled, review_requested] issue_comment: types: [created] @@ -54,25 +57,39 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request_target" ]; then echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + # Used to fire the "PR created" message/fork comment only once, + # on the first label landing — later relabels (e.g. after a + # synchronize adds another product label) shouldn't re-notify. + LABEL_COUNT=$(jq '.pull_request.labels | length' "$GITHUB_EVENT_PATH") + echo "label_count=$LABEL_COUNT" >> "$GITHUB_OUTPUT" else echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" fi - - name: Resolve CODEOWNERS teams for changed files + - name: Resolve CODEOWNERS teams from PR labels id: resolve-teams env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - FILES=$(gh pr view ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --json files --jq '.files[].path') - mapfile -t FILE_ARRAY <<< "$FILES" - TEAMS=$(node scripts/resolve-codeowners-teams.mjs "${FILE_ARRAY[@]}") + LABELS=$(gh pr view ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') + MAPPING=$(cat .github/label-codeowners.json) + + TEAMS="" + while IFS= read -r label; do + [ -z "$label" ] && continue + team=$(echo "$MAPPING" | jq -r --arg l "$label" '.[$l] // empty') + if [ -n "$team" ] && [[ ",$TEAMS," != *",$team,"* ]]; then + TEAMS="${TEAMS:+$TEAMS,}$team" + fi + done <<< "$LABELS" + echo "teams=$TEAMS" >> "$GITHUB_OUTPUT" - name: Build message id: build-message run: | # Build the notification text based on which event/action fired. - if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.action }}" = "opened" ]; then + if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.action }}" = "labeled" ]; then MESSAGE="PR created: ${{ github.event.pull_request.html_url }}" elif [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.action }}" = "review_requested" ]; then MESSAGE="Review requested on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.pull_request.html_url }}" @@ -84,10 +101,12 @@ jobs: - name: Comment on fork PRs tagging codeowners # Fork contributors and reviewers may not be in the Slack channel, # so give them a visible, on-PR codeowners tag too. Only needed - # once, on open — review_requested/comments already notify Slack. + # once, on the first label landing — review_requested/comments + # already notify Slack. if: >- github.event_name == 'pull_request_target' && - github.event.action == 'opened' && + github.event.action == 'labeled' && + steps.pr-info.outputs.label_count == '1' && github.event.pull_request.head.repo.full_name != github.repository && steps.resolve-teams.outputs.teams != '' env: @@ -98,6 +117,9 @@ jobs: gh pr comment ${{ steps.pr-info.outputs.number }} --repo ${{ github.repository }} --body "Thanks for the contribution! Tagging codeowners for review: $MENTIONS" - name: Send Slack notification + # Only fire once for a `labeled` event — the first label landing. + # review_requested/comment events always notify. + if: github.event_name != 'pull_request_target' || github.event.action != 'labeled' || steps.pr-info.outputs.label_count == '1' env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} NOTIFY_MESSAGE: ${{ steps.build-message.outputs.message }} diff --git a/.github/workflows/sync-dev-to-main.yml b/.github/workflows/sync-dev-to-main.yml index b2302e6929..c2aca8bed4 100644 --- a/.github/workflows/sync-dev-to-main.yml +++ b/.github/workflows/sync-dev-to-main.yml @@ -19,8 +19,6 @@ permissions: env: SOURCE_BRANCH: dev TARGET_BRANCH: main - # Teams webhook URL stored in GitHub Secrets for security - TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} # Slack webhook URL stored in GitHub Secrets for security SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -173,40 +171,6 @@ jobs: echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT - - name: Send success notification to Teams - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' - run: | - # Get commit messages (truncate if too long) - COMMITS=$(cat commits.txt | head -5 | sed 's/^/ - /') - - curl -H "Content-Type: application/json" -d '{ - "@type": "MessageCard", - "@context": "https://schema.org/extensions", - "summary": "Dev → Main Sync Successful", - "themeColor": "28a745", - "title": "✅ Automated Sync: dev → main", - "sections": [{ - "activityTitle": "Successfully merged '${{ steps.check-new-commits.outputs.commits_ahead }}' commits", - "activitySubtitle": "Repository: netwrix/docs", - "facts": [ - {"name": "Commits merged:", "value": "${{ steps.merge-details.outputs.commit_count }}"}, - {"name": "Latest commit:", "value": "${{ steps.merge-details.outputs.commit_sha }}"}, - {"name": "Triggered by:", "value": "${{ github.event_name }}"}, - {"name": "Workflow:", "value": "${{ github.workflow }}"} - ], - "text": "**Recent commits:**\n\n'"$(echo "$COMMITS" | sed 's/$/\\n/' | tr -d '\n')"'" - }], - "potentialAction": [{ - "@type": "OpenUri", - "name": "View Workflow Run", - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}] - }, { - "@type": "OpenUri", - "name": "View Repository", - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}"}] - }] - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" - - name: Send success notification to Slack if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'false' run: | @@ -257,57 +221,15 @@ jobs: console.log('Created issue #' + issue.data.number); - - name: Send conflict notification to Teams - if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' - run: | - curl -H "Content-Type: application/json" -d '{ - "@type": "MessageCard", - "@context": "https://schema.org/extensions", - "summary": "Dev → Main Sync Blocked by Conflict", - "themeColor": "ff9800", - "title": "⚠️ Automated Sync Blocked: Merge Conflict", - "sections": [{ - "activityTitle": "Manual intervention required", - "activitySubtitle": "Repository: netwrix/docs", - "facts": [ - {"name": "Source branch:", "value": "${{ env.SOURCE_BRANCH }}"}, - {"name": "Target branch:", "value": "${{ env.TARGET_BRANCH }}"}, - {"name": "Commits waiting:", "value": "${{ steps.check-new-commits.outputs.commits_ahead }}"}, - {"name": "Status:", "value": "Merge conflict detected"} - ], - "text": "The automated sync encountered merge conflicts. A GitHub issue has been created with details. Please resolve the conflicts manually." - }], - "potentialAction": [{ - "@type": "OpenUri", - "name": "View Workflow Run", - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}] - }, { - "@type": "OpenUri", - "name": "View Issues", - "targets": [{"os": "default", "uri": "${{ github.server_url }}/${{ github.repository }}/issues?q=is:issue+is:open+label:merge-conflict"}] - }] - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" - - name: Send conflict notification to Slack if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' run: | node scripts/notify-slack.mjs --message "⚠️ dev→main sync blocked by merge conflict: ${{ steps.check-new-commits.outputs.commits_ahead }} commits waiting. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" - - name: Send skip notification to Teams + - name: Send skip notification to Slack if: steps.check-new-commits.outputs.has_new_commits == 'false' run: | - curl -H "Content-Type: application/json" -d '{ - "@type": "MessageCard", - "@context": "https://schema.org/extensions", - "summary": "No Changes to Sync", - "themeColor": "0078D4", - "title": "ℹ️ No Changes to Sync", - "sections": [{ - "activityTitle": "Branches are already in sync", - "activitySubtitle": "Repository: netwrix/docs", - "text": "No new commits found in dev branch. Nothing to sync." - }] - }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" + node scripts/notify-slack.mjs --message "ℹ️ dev→main sync: no changes to sync. ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" - name: Send failure notification to Slack if: failure() diff --git a/scripts/resolve-labels-for-teams.mjs b/scripts/resolve-labels-for-teams.mjs new file mode 100644 index 0000000000..0a00bfa882 --- /dev/null +++ b/scripts/resolve-labels-for-teams.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +/** + * Reverse-map CODEOWNERS team handles to their product labels via + * .github/label-codeowners.json (label -> team), for PR auto-labeling. + * + * Usage: + * node scripts/resolve-labels-for-teams.mjs "@netwrix/foo-docs,@netwrix/bar-docs" + * + * Prints a comma-joined, deduped list of matching labels to stdout. + */ + +import fs from 'fs'; +import path from 'path'; + +const PROJECT_ROOT = process.cwd(); +const MAPPING_PATH = path.join(PROJECT_ROOT, '.github', 'label-codeowners.json'); + +function main() { + const teamsArg = process.argv[2] || ''; + const teams = teamsArg.split(',').map(t => t.trim()).filter(Boolean); + + if (teams.length === 0) { + process.stdout.write(''); + return; + } + + const mapping = JSON.parse(fs.readFileSync(MAPPING_PATH, 'utf8')); + const teamSet = new Set(teams); + const labels = []; + + for (const [label, team] of Object.entries(mapping)) { + if (teamSet.has(team) && !labels.includes(label)) { + labels.push(label); + } + } + + process.stdout.write(labels.join(',')); +} + +main();