Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/label-pr.yml
Original file line number Diff line number Diff line change
@@ -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[@]}"
33 changes: 23 additions & 10 deletions .github/workflows/slack-notify-issue.yml
Original file line number Diff line number Diff line change
@@ -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]

Expand Down Expand Up @@ -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)
Expand All @@ -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"

Expand Down
60 changes: 41 additions & 19 deletions .github/workflows/slack-notify-pr.yml
Original file line number Diff line number Diff line change
@@ -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]

Expand Down Expand Up @@ -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 }}"
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down
82 changes: 2 additions & 80 deletions .github/workflows/sync-dev-to-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions scripts/resolve-labels-for-teams.mjs
Original file line number Diff line number Diff line change
@@ -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();