diff --git a/.github/label-codeowners.json b/.github/label-codeowners.json index dd0f72f3cb..e11800a119 100644 --- a/.github/label-codeowners.json +++ b/.github/label-codeowners.json @@ -8,6 +8,7 @@ "data-classification": "@netwrix/dataclassification-docs", "directory-manager": "@netwrix/directorymanager-docs", "endpoint-policy-manager": "@netwrix/endpointpolicymanager-docs", + "policypak": "@netwrix/endpointpolicymanager-docs", "endpoint-protector": "@netwrix/endpointprotector-docs", "identity-manager": "@netwrix/identitymanager-docs", "identity-recovery": "@netwrix/recoveryforactivedirectory-docs", diff --git a/.github/team-slack-map.json b/.github/team-slack-map.json new file mode 100644 index 0000000000..dd7ba6833b --- /dev/null +++ b/.github/team-slack-map.json @@ -0,0 +1,29 @@ +{ + "_comment": "Maps each CODEOWNERS/label-codeowners team to the Slack member IDs to @-mention in #docs-gh. Find a member ID via their Slack profile -> \"...\" menu -> Copy member ID. Empty arrays post without an @-mention until filled in.", + "@netwrix/1secure-docs": [], + "@netwrix/accessanalyzer-docs": [], + "@netwrix/accessinformationcenter-docs": [], + "@netwrix/activitymonitor-docs": [], + "@netwrix/auditor-docs": [], + "@netwrix/changetracker-docs": [], + "@netwrix/dataclassification-docs": [], + "@netwrix/directorymanager-docs": [], + "@netwrix/endpointpolicymanager-docs": [], + "@netwrix/endpointprotector-docs": [], + "@netwrix/identitymanager-docs": [], + "@netwrix/passwordpolicyenforcer-docs": [], + "@netwrix/passwordreset-docs": [], + "@netwrix/passwordsecure-docs": [], + "@netwrix/pingcastle-docs": [], + "@netwrix/platgovnetsuite-docs": [], + "@netwrix/platgovnetsuiteflashlight-docs": [], + "@netwrix/platgovsalesforce-docs": [], + "@netwrix/platgovsalesforceflashlight-docs": [], + "@netwrix/privilegesecure-docs": [], + "@netwrix/privilegesecurediscovery-docs": [], + "@netwrix/recoveryforactivedirectory-docs": [], + "@netwrix/threatmanager-docs": [], + "@netwrix/threatprevention-docs": [], + "@netwrix/kb-docs": [], + "@netwrix/training-docs": [] +} diff --git a/.github/workflows/slack-notify-issue.yml b/.github/workflows/slack-notify-issue.yml new file mode 100644 index 0000000000..d462b16d28 --- /dev/null +++ b/.github/workflows/slack-notify-issue.yml @@ -0,0 +1,97 @@ +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. + +on: + issues: + types: [opened] + issue_comment: + types: [created] + +concurrency: + group: slack-notify-issue-${{ github.event.issue.number }} + cancel-in-progress: false + +permissions: + contents: read + issues: read + +jobs: + notify: + # Bot-authored comments (Vale autofix, Claude reviewers, etc.) are + # excluded to avoid Slack spam from automated commentary. + if: ${{ !github.event.issue.pull_request && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + .github + scripts + sparse-checkout-cone-mode: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + + - name: Determine codeowner teams + id: codeowners + env: + 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. + 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) + if echo "$LABELS" | grep -q "^kb/"; then + echo "Issue has kb/ label — skipping Slack notification" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Skip for Admin Support PR review tracking issues + if echo "$ISSUE_TITLE" | grep -q "^Admin: PR review"; then + echo "Admin Support tracking issue — skipping Slack notification" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Read the mapping file + MAPPING=$(cat .github/label-codeowners.json) + + # Collect matched teams (deduplicated), comma-joined + TEAMS="" + while IFS= read -r label; do + 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 + fi + done <<< "$LABELS" + + echo "teams=$TEAMS" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Send Slack notification + if: steps.codeowners.outputs.skip != 'true' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + NOTIFY_TEAMS: ${{ steps.codeowners.outputs.teams }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + COMMENT_URL: ${{ github.event.comment.html_url }} + run: | + if [ "${{ github.event_name }}" = "issues" ]; then + MESSAGE="Issue created: $ISSUE_URL" + else + MESSAGE="Comment on Issue #$ISSUE_NUMBER: $COMMENT_URL" + fi + + node scripts/notify-slack.mjs --message "$MESSAGE" --teams "$NOTIFY_TEAMS" || echo "Failed to send Slack notification" diff --git a/.github/workflows/slack-notify-pr.yml b/.github/workflows/slack-notify-pr.yml new file mode 100644 index 0000000000..6e5385ec86 --- /dev/null +++ b/.github/workflows/slack-notify-pr.yml @@ -0,0 +1,106 @@ +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. + +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] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: slack-notify-pr-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +jobs: + notify: + # issue_comment fires for both issue comments and PR comments — only + # continue here when the comment is on a PR. Plain issue comments are + # handled by the separate issue-intake workflow. Bot-authored comments + # (Vale autofix, Claude reviewers, etc.) are excluded to avoid Slack + # spam from automated commentary. + if: (github.event_name == 'pull_request_target' || github.event.issue.pull_request) && (github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot') + 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_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + + - name: Determine PR number + id: pr-info + run: | + if [ "${{ github.event_name }}" = "pull_request_target" ]; then + echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + else + echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve CODEOWNERS teams for changed files + 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[@]}") + 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 + 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 }}" + else + MESSAGE="Comment on PR #${{ steps.pr-info.outputs.number }}: ${{ github.event.comment.html_url }}" + fi + echo "message=$MESSAGE" >> "$GITHUB_OUTPUT" + + - 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. + if: >- + github.event_name == 'pull_request_target' && + github.event.action == 'opened' && + github.event.pull_request.head.repo.full_name != github.repository && + steps.resolve-teams.outputs.teams != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NOTIFY_TEAMS: ${{ steps.resolve-teams.outputs.teams }} + run: | + MENTIONS=$(echo "$NOTIFY_TEAMS" | tr ',' ' ') + 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 + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + NOTIFY_MESSAGE: ${{ steps.build-message.outputs.message }} + NOTIFY_TEAMS: ${{ steps.resolve-teams.outputs.teams }} + run: | + node scripts/notify-slack.mjs --message "$NOTIFY_MESSAGE" --teams "$NOTIFY_TEAMS" || echo "Failed to send Slack notification" diff --git a/.github/workflows/sync-dev-to-main.yml b/.github/workflows/sync-dev-to-main.yml index c1d9b925ad..b2302e6929 100644 --- a/.github/workflows/sync-dev-to-main.yml +++ b/.github/workflows/sync-dev-to-main.yml @@ -21,6 +21,8 @@ env: 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 }} jobs: sync-branches: @@ -33,6 +35,11 @@ jobs: fetch-depth: 0 # Full history for merge token: ${{ secrets.SYNC_PAT }} + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + - name: Configure git run: | git config user.name "github-actions[bot]" @@ -62,7 +69,7 @@ jobs: run: | echo "⏳ Waiting for 'build-and-deploy.yml' workflow to complete on ${{ env.SOURCE_BRANCH }}..." - MAX_WAIT=1800 # 30 minutes max wait + MAX_WAIT=2700 # 45 minutes max wait WAIT_INTERVAL=30 # Check every 30 seconds ELAPSED=0 @@ -200,6 +207,11 @@ jobs: }] }' "${{ 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: | + node scripts/notify-slack.mjs --message "✅ dev→main sync succeeded: ${{ steps.merge-details.outputs.commit_count }} commits merged (latest ${{ steps.merge-details.outputs.commit_sha }}). ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" + - name: Create GitHub issue for conflict if: steps.check-new-commits.outputs.has_new_commits == 'true' && steps.merge.outputs.conflict == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -276,6 +288,11 @@ jobs: }] }' "${{ 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 if: steps.check-new-commits.outputs.has_new_commits == 'false' run: | @@ -292,6 +309,11 @@ jobs: }] }' "${{ env.TEAMS_WEBHOOK_URL }}" || echo "Failed to send Teams notification" + - name: Send failure notification to Slack + if: failure() + run: | + node scripts/notify-slack.mjs --message "❌ dev→main sync failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" || echo "Failed to send Slack notification" + - name: Workflow summary if: always() run: | diff --git a/scripts/notify-slack.mjs b/scripts/notify-slack.mjs new file mode 100644 index 0000000000..e48dce3000 --- /dev/null +++ b/scripts/notify-slack.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Post a Slack notification to #docs-gh via an Incoming Webhook. + * + * Usage: + * node scripts/notify-slack.mjs --message "PR created: https://github.com/netwrix/docs/pull/123" --teams "@netwrix/changetracker-docs,@netwrix/kb-docs" + * node scripts/notify-slack.mjs --message "dev merged to main" + * + * Environment variables: + * SLACK_WEBHOOK_URL Incoming Webhook URL (required) + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const TEAM_MAP_PATH = path.join(PROJECT_ROOT, '.github', 'team-slack-map.json'); + +function parseArgs(argv) { + const args = { message: null, teams: null }; + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--message') { + args.message = argv[++i]; + } else if (argv[i] === '--teams') { + args.teams = argv[++i]; + } + } + + return args; +} + +function resolveMentions(teamsArg, teamMap) { + if (!teamsArg) return []; + + const teams = teamsArg.split(',').map(t => t.trim()).filter(Boolean); + const ids = new Set(); + + for (const team of teams) { + if (team === '_comment') continue; + + const teamIds = teamMap[team]; + + if (!teamIds || !Array.isArray(teamIds) || teamIds.length === 0) { + console.error(`Warning: no Slack member IDs found for team "${team}"`); + continue; + } + + teamIds.forEach(id => ids.add(id)); + } + + return [...ids].map(id => `<@${id}>`); +} + +async function main() { + const { message, teams } = parseArgs(process.argv.slice(2)); + + if (!message) { + console.error('Error: --message is required'); + process.exit(1); + } + + const webhookUrl = process.env.SLACK_WEBHOOK_URL; + + if (!webhookUrl) { + console.error('Error: SLACK_WEBHOOK_URL environment variable is not set'); + process.exit(1); + } + + const teamMap = JSON.parse(fs.readFileSync(TEAM_MAP_PATH, 'utf8')); + const mentions = resolveMentions(teams, teamMap); + const text = mentions.length > 0 ? `${message} ${mentions.join(' ')}` : message; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + + if (!response.ok) { + const body = await response.text(); + console.error(`Error: Slack webhook returned ${response.status}: ${body}`); + process.exit(1); + } + + console.log('Slack notification sent'); +} + +main().catch(err => { + console.error(`Error: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/resolve-codeowners-teams.mjs b/scripts/resolve-codeowners-teams.mjs new file mode 100644 index 0000000000..700aee45de --- /dev/null +++ b/scripts/resolve-codeowners-teams.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * Resolve CODEOWNERS teams for a set of changed files + * + * Usage: + * node scripts/resolve-codeowners-teams.mjs ... + * + * Prints a comma-joined, deduped list of matching team handles to stdout + * (no trailing newline handling required by caller). Prints nothing if + * no files match any pattern. + */ + +import fs from 'fs'; +import path from 'path'; + +const PROJECT_ROOT = process.cwd(); +const CODEOWNERS_PATH = path.join(PROJECT_ROOT, '.github', 'CODEOWNERS'); + +function parseCodeowners(content) { + const rules = []; + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(/\s+/); + const pattern = parts[0]; + const owners = parts.slice(1); + if (owners.length === 0) continue; + + const anchored = pattern.startsWith('/') ? pattern.slice(1) : pattern; + const stripped = anchored.endsWith('/') ? anchored.slice(0, -1) : anchored; + rules.push({ pattern: stripped, owners }); + } + + return rules; +} + +// Only plain directory/file prefixes are supported — no glob support (`*`, +// `**`, `*.md`). Every active CODEOWNERS rule today is a plain prefix; a +// future wildcard rule would silently match nothing here. +function matchFile(rules, filePath) { + let matchedOwners = null; + + for (const rule of rules) { + // CODEOWNERS treats a non-wildcard pattern as matching both the exact + // path and, recursively, everything under it — trailing slash or not. + if (filePath === rule.pattern || filePath.startsWith(`${rule.pattern}/`)) { + matchedOwners = rule.owners; + } + } + + return matchedOwners; +} + +function main() { + const files = process.argv.slice(2); + const content = fs.readFileSync(CODEOWNERS_PATH, 'utf8'); + const rules = parseCodeowners(content); + + const teams = []; + for (const file of files) { + const owners = matchFile(rules, file); + if (!owners) continue; + for (const owner of owners) { + if (!teams.includes(owner)) teams.push(owner); + } + } + + process.stdout.write(teams.join(',')); +} + +main();