Skip to content

#0: Fix noc_async_write_multicast to pass noc when using one packet version #18314

#0: Fix noc_async_write_multicast to pass noc when using one packet version

#0: Fix noc_async_write_multicast to pass noc when using one packet version #18314

name: CodeOwners Group Analysis
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch name to analyze (e.g., feature-branch-name)'
required: true
type: string
create-new-comment:
description: 'Create a new comment instead of editing existing one'
required: false
default: false
type: boolean
ping-pending-owners:
description: 'Ping 2 random owners from pending approval groups'
required: false
default: false
type: boolean
send-slack-notification:
description: 'Send ping notification to Slack channel'
required: false
default: false
type: boolean
author-notes:
description: 'Optional message from PR author to include in notifications'
required: false
default: ''
type: string
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
parse-comment:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
runs-on: ubuntu-latest
outputs:
should-run: ${{ steps.parse.outputs.should-run }}
branch: ${{ steps.parse.outputs.branch }}
create-new-comment: ${{ steps.parse.outputs.create-new-comment }}
ping-pending-owners: ${{ steps.parse.outputs.ping-pending-owners }}
send-slack-notification: ${{ steps.parse.outputs.send-slack-notification }}
pr-number: ${{ steps.parse.outputs.pr-number }}
author-notes: ${{ steps.parse.outputs.author-notes }}
direct-ping-users: ${{ steps.parse.outputs.direct-ping-users }}
is-direct-ping: ${{ steps.parse.outputs.is-direct-ping }}
comment-author: ${{ steps.parse.outputs.comment-author }}
is-bypass-command: ${{ steps.parse.outputs.is-bypass-command }}
bypass-authorized: ${{ steps.parse.outputs.bypass-authorized }}
steps:
- name: Parse comment and get PR info
id: parse
run: |
# Properly escape comment body to handle quotes
COMMENT_BODY=$(cat <<'COMMENT_EOF'
${{ github.event.comment.body }}
COMMENT_EOF
)
PR_NUMBER="${{ github.event.issue.number }}"
COMMENT_AUTHOR="${{ github.event.comment.user.login }}"
echo "Comment body: $COMMENT_BODY"
echo "PR number: $PR_NUMBER"
echo "Comment author: $COMMENT_AUTHOR"
# Check if comment contains our trigger commands
if echo "$COMMENT_BODY" | grep -E "^/(codeowners?|ping)(\s|$)" > /dev/null; then
# Add reaction to acknowledge we saw the command
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "eyes"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
# Check if comment author is a member of tenstorrent organization
MEMBERSHIP_API="https://api.github.com/orgs/tenstorrent/members/$COMMENT_AUTHOR"
MEMBERSHIP_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$MEMBERSHIP_API")
echo "Membership check for $COMMENT_AUTHOR: HTTP $MEMBERSHIP_HTTP_CODE"
# HTTP 204 = member, HTTP 404 = not a member or private membership, HTTP 302 = public member
if [ "$MEMBERSHIP_HTTP_CODE" = "204" ] || [ "$MEMBERSHIP_HTTP_CODE" = "302" ]; then
echo "✅ User $COMMENT_AUTHOR is a member of tenstorrent organization"
echo "should-run=true" >> $GITHUB_OUTPUT
echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT
# Get PR branch information
PR_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER"
PR_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
BRANCH=$(echo "$PR_DATA" | jq -r '.head.ref')
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "Found branch: $BRANCH"
# Parse command options
if echo "$COMMENT_BODY" | grep -E "^/codeowners?\s+bypass(\s|$)" > /dev/null; then
echo "create-new-comment=false" >> $GITHUB_OUTPUT
echo "ping-pending-owners=false" >> $GITHUB_OUTPUT
echo "send-slack-notification=false" >> $GITHUB_OUTPUT
echo "is-direct-ping=false" >> $GITHUB_OUTPUT
echo "is-bypass-command=true" >> $GITHUB_OUTPUT
echo "Command: bypass approval"
# Check if user is member of metalium-developers-infra team
INFRA_TEAM_API="https://api.github.com/orgs/tenstorrent/teams/metalium-developers-infra/members"
INFRA_MEMBERS_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$INFRA_TEAM_API" 2>/dev/null)
# Verify the API call was successful and returned valid JSON
if ! echo "$INFRA_MEMBERS_DATA" | jq -e '.[0]' > /dev/null 2>&1; then
echo "❌ Failed to fetch team members, denying access"
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
else
# Use jq to perform exact username matching
IS_MEMBER=$(echo "$INFRA_MEMBERS_DATA" | jq -r --arg user "$COMMENT_AUTHOR" '[.[].login] | contains([$user])')
if [ "$IS_MEMBER" = "true" ]; then
echo "✅ User $COMMENT_AUTHOR is authorized (metalium-developers-infra member)"
echo "bypass-authorized=true" >> $GITHUB_OUTPUT
else
echo "❌ User $COMMENT_AUTHOR is NOT authorized (not in metalium-developers-infra)"
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
fi
fi
elif echo "$COMMENT_BODY" | grep -E "^/codeowners?\s+new(\s|$)" > /dev/null; then
echo "create-new-comment=true" >> $GITHUB_OUTPUT
echo "ping-pending-owners=false" >> $GITHUB_OUTPUT
echo "send-slack-notification=false" >> $GITHUB_OUTPUT
echo "is-direct-ping=false" >> $GITHUB_OUTPUT
echo "is-bypass-command=false" >> $GITHUB_OUTPUT
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
echo "Command: new comment"
elif echo "$COMMENT_BODY" | grep -E "^/codeowners?\s+ping(\s|$)" > /dev/null; then
echo "create-new-comment=false" >> $GITHUB_OUTPUT
echo "ping-pending-owners=true" >> $GITHUB_OUTPUT
echo "send-slack-notification=true" >> $GITHUB_OUTPUT
echo "is-direct-ping=false" >> $GITHUB_OUTPUT
echo "is-bypass-command=false" >> $GITHUB_OUTPUT
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
echo "Command: ping owners"
elif echo "$COMMENT_BODY" | grep -E "^/ping\s+" > /dev/null; then
echo "create-new-comment=false" >> $GITHUB_OUTPUT
echo "ping-pending-owners=false" >> $GITHUB_OUTPUT
echo "send-slack-notification=true" >> $GITHUB_OUTPUT
echo "is-direct-ping=true" >> $GITHUB_OUTPUT
echo "is-bypass-command=false" >> $GITHUB_OUTPUT
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
echo "Command: direct ping"
# Extract GitHub usernames and optional author notes from /ping command
# Format: /ping @user1 @team1/subteam optional message (quotes optional)
FULL_TEXT=$(echo "$COMMENT_BODY" | sed 's/^\/ping\s\+//')
# Extract all @mentions for ping users
PING_USERS=$(echo "$FULL_TEXT" | grep -oE '@[a-zA-Z0-9_/-]+' | tr '\n' ',' | sed 's/,$//')
echo "direct-ping-users=$PING_USERS" >> $GITHUB_OUTPUT
echo "Found users to ping: $PING_USERS"
# Extract message (everything after last @mention, strip quotes if present)
if [ -n "$PING_USERS" ]; then
# Remove all @mentions to get just the message
MSG_TEXT=$(echo "$FULL_TEXT" | sed 's/@[a-zA-Z0-9_/-]\+//g' | sed 's/^\s*//')
if [ -n "$MSG_TEXT" ]; then
# Strip leading and trailing quotes (handles both complete and incomplete quoting)
AUTHOR_NOTES=$(echo "$MSG_TEXT" | sed -e 's/^["'"'"']\+//' -e 's/["'"'"']\+$//')
echo "Found ping message: $AUTHOR_NOTES"
fi
fi
else
# Default: update existing comment
echo "create-new-comment=false" >> $GITHUB_OUTPUT
echo "ping-pending-owners=false" >> $GITHUB_OUTPUT
echo "send-slack-notification=false" >> $GITHUB_OUTPUT
echo "is-direct-ping=false" >> $GITHUB_OUTPUT
echo "is-bypass-command=false" >> $GITHUB_OUTPUT
echo "bypass-authorized=false" >> $GITHUB_OUTPUT
echo "Command: update existing comment"
fi
# Extract author notes from /codeowners ping command if provided
if [ -z "$AUTHOR_NOTES" ]; then
if echo "$COMMENT_BODY" | grep -E "^/codeowners?\s+ping" > /dev/null; then
# Extract everything after /codeowners ping (handle both same line and new line)
RAW_TEXT=$(echo "$COMMENT_BODY" | sed -n 's/^\/codeowners\?\s\+ping\s*\(.*\)$/\1/p' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
# If no content on same line, check for content after removing the command line entirely
if [ -z "$RAW_TEXT" ]; then
RAW_TEXT=$(echo "$COMMENT_BODY" | sed '0,/^\/codeowners\?\s\+ping\s*$/d' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
fi
if [ -n "$RAW_TEXT" ]; then
# Strip leading and trailing quotes (handles both complete and incomplete quoting)
AUTHOR_NOTES=$(echo "$RAW_TEXT" | sed -e 's/^["'"'"']\+//' -e 's/["'"'"']\+$//')
echo "Found author notes: $AUTHOR_NOTES"
fi
fi
fi
echo "author-notes=$AUTHOR_NOTES" >> $GITHUB_OUTPUT
echo "comment-author=$COMMENT_AUTHOR" >> $GITHUB_OUTPUT
# Add success reaction
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "rocket"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
else
echo "❌ User $COMMENT_AUTHOR is not a member of tenstorrent organization (HTTP $MEMBERSHIP_HTTP_CODE)"
echo "should-run=false" >> $GITHUB_OUTPUT
# Add unauthorized reaction
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "-1"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
# Post a reply comment explaining the restriction
REPLY_MESSAGE="❌ **Access Denied**: Only members of the \`tenstorrent\` organization can use CodeOwners commands.\n\nIf you believe this is an error, please contact a repository maintainer."
# Create JSON payload for the reply
TEMP_JSON_FILE=$(mktemp)
jq -n --arg body "$REPLY_MESSAGE" '{"body": $body}' > "$TEMP_JSON_FILE"
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
rm -f "$TEMP_JSON_FILE"
fi
else
echo "should-run=false" >> $GITHUB_OUTPUT
echo "Comment does not contain trigger commands"
fi
find-pr:
needs: [parse-comment]
if: always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && needs.parse-comment.outputs.should-run == 'true'))
runs-on: ubuntu-latest
outputs:
pr-number: ${{ steps.find-pr.outputs.pr-number }}
pr-exists: ${{ steps.find-pr.outputs.pr-exists }}
steps:
- name: Find PR for branch
id: find-pr
run: |
# Determine branch and PR based on trigger type
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ github.event.inputs.branch }}"
echo "Manual trigger - Looking for PR associated with branch: $BRANCH"
# Use GitHub API to find PR for the branch
API_URL="https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:$BRANCH&state=open"
echo "API URL: $API_URL"
# Get PR data
PR_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$API_URL")
# Extract PR number
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.[0].number // empty')
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
echo "Found PR #$PR_NUMBER for branch $BRANCH"
echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "pr-exists=true" >> $GITHUB_OUTPUT
else
echo "No open PR found for branch $BRANCH"
echo "pr-number=" >> $GITHUB_OUTPUT
echo "pr-exists=false" >> $GITHUB_OUTPUT
fi
else
# Comment trigger - PR info is already available
PR_NUMBER="${{ needs.parse-comment.outputs.pr-number }}"
BRANCH="${{ needs.parse-comment.outputs.branch }}"
echo "Comment trigger - Using PR #$PR_NUMBER for branch $BRANCH"
echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "pr-exists=true" >> $GITHUB_OUTPUT
fi
bypass-approval:
needs: [parse-comment, find-pr]
if: needs.parse-comment.outputs.is-bypass-command == 'true' && needs.parse-comment.outputs.bypass-authorized == 'true' && needs.find-pr.outputs.pr-exists == 'true'
runs-on: ubuntu-latest
steps:
- name: Approve PR
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
COMMENT_AUTHOR="${{ needs.parse-comment.outputs.comment-author }}"
echo "Approving PR #$PR_NUMBER as requested by authorized user: $COMMENT_AUTHOR"
# Create approval
REVIEW_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER/reviews"
REVIEW_PAYLOAD=$(jq -n \
--arg event "APPROVE" \
--arg body "✅ CodeOwners bypass approval granted by @$COMMENT_AUTHOR (metalium-developers-infra team)" \
'{
event: $event,
body: $body
}')
REVIEW_RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "$REVIEW_PAYLOAD" \
"$REVIEW_API")
REVIEW_ID=$(echo "$REVIEW_RESPONSE" | jq -r '.id // empty')
if [ -n "$REVIEW_ID" ] && [ "$REVIEW_ID" != "null" ]; then
echo "✅ Successfully approved PR #$PR_NUMBER (Review ID: $REVIEW_ID)"
# Add success reaction to the command comment
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "+1"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
else
echo "❌ Failed to approve PR #$PR_NUMBER"
echo "Response: $REVIEW_RESPONSE"
# Post failure comment with explanation
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d "{\"body\": \"❌ **Bypass Approval Failed**\\n\\nFailed to approve PR #$PR_NUMBER. This might be a temporary issue with the GitHub API.\\n\\nPlease try again or contact the infra team if the problem persists.\"}" \
"https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
# Add failure reaction
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "-1"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
exit 1
fi
- name: Post confirmation comment
if: success()
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
COMMENT_AUTHOR="${{ needs.parse-comment.outputs.comment-author }}"
CONFIRMATION="✅ **CodeOwners Bypass Approval Granted**\n\nThis PR has been approved by @$COMMENT_AUTHOR (metalium-developers-infra team) using the bypass mechanism.\n\n⚠️ **Note:** This bypass should only be used for emergency fixes or when standard approval process is blocked."
TEMP_JSON_FILE=$(mktemp)
jq -n --arg body "$CONFIRMATION" '{"body": $body}' > "$TEMP_JSON_FILE"
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
rm -f "$TEMP_JSON_FILE"
echo "Confirmation comment posted for PR #$PR_NUMBER"
bypass-unauthorized:
needs: [parse-comment, find-pr]
if: needs.parse-comment.outputs.is-bypass-command == 'true' && needs.parse-comment.outputs.bypass-authorized == 'false' && needs.find-pr.outputs.pr-exists == 'true'
runs-on: ubuntu-latest
steps:
- name: Post unauthorized message
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
COMMENT_AUTHOR="${{ needs.parse-comment.outputs.comment-author }}"
UNAUTHORIZED_MSG="❌ **Access Denied**: The \`/codeowners bypass\` command can only be used by members of the \`@tenstorrent/metalium-developers-infra\` team.\n\nUser @$COMMENT_AUTHOR is not authorized to use this command.\n\nIf you need bypass approval, please contact a member of the infra team."
TEMP_JSON_FILE=$(mktemp)
jq -n --arg body "$UNAUTHORIZED_MSG" '{"body": $body}' > "$TEMP_JSON_FILE"
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
rm -f "$TEMP_JSON_FILE"
# Add thumbs down reaction to the command comment
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
-d '{"content": "-1"}' \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions"
get-reviews:
needs: [find-pr]
if: always() && needs.find-pr.outputs.pr-exists == 'true'
runs-on: ubuntu-latest
outputs:
approved-reviewers: ${{ steps.get-reviews.outputs.approved-reviewers }}
steps:
- name: Get PR reviews and team members
id: get-reviews
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
# Get ALL PR reviews using GitHub API with pagination
ALL_REVIEWS_DATA="[]"
PAGE=1
while true; do
REVIEWS_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER/reviews?per_page=100&page=$PAGE"
REVIEWS_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$REVIEWS_API" 2>/dev/null)
# Check if API call was successful
REVIEWS_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$REVIEWS_API")
if [ "$REVIEWS_HTTP_CODE" = "200" ] && [ -n "$REVIEWS_DATA" ] && [ "$REVIEWS_DATA" != "null" ] && [ "$REVIEWS_DATA" != "[]" ]; then
# Merge this page with existing reviews
ALL_REVIEWS_DATA=$(echo "$ALL_REVIEWS_DATA $REVIEWS_DATA" | jq -s '.[0] + .[1]')
# Check if we got a full page (100 items), if not, we're done
PAGE_COUNT=$(echo "$REVIEWS_DATA" | jq '. | length')
if [ "$PAGE_COUNT" -lt 100 ]; then
break
fi
PAGE=$((PAGE + 1))
# Safety check to avoid infinite loop
if [ $PAGE -gt 10 ]; then
echo "Warning: Too many review pages, stopping at page $PAGE"
break
fi
else
if [ $PAGE -eq 1 ]; then
echo "Warning: Could not fetch PR reviews (HTTP $REVIEWS_HTTP_CODE)"
fi
break
fi
done
echo "Fetched $(echo "$ALL_REVIEWS_DATA" | jq '. | length') total reviews across $((PAGE-1)) pages"
# Extract approved reviews (get the LATEST review from each user)
if [ "$(echo "$ALL_REVIEWS_DATA" | jq '. | length')" -gt 0 ]; then
# First, let's see all reviewers for debugging
echo "All reviews found:"
echo "$ALL_REVIEWS_DATA" | jq -r '.[] | "\(.user.login): \(.state) (\(.submitted_at))"' 2>/dev/null || echo "Failed to parse reviews"
# Get latest review from each user, then filter for APPROVED
APPROVED_REVIEWERS=$(echo "$ALL_REVIEWS_DATA" | jq -r '
[.[] | {user: .user.login, state: .state, date: .submitted_at}] |
group_by(.user) |
map(
# For each user, get their latest state-changing review (ignore COMMENTED)
map(select(.state != "COMMENTED")) |
sort_by(.date) |
reverse |
.[0] // empty
) |
map(select(.state == "APPROVED")) |
.[].user
' 2>/dev/null | tr '\n' ',' | sed 's/,$//')
echo "Final approved reviewers: $APPROVED_REVIEWERS"
else
echo "Warning: No reviews found"
APPROVED_REVIEWERS=""
fi
echo "approved-reviewers=$APPROVED_REVIEWERS" >> $GITHUB_OUTPUT
analyze-codeowners:
needs: [find-pr, get-reviews, parse-comment]
if: always() && needs.find-pr.outputs.pr-exists == 'true' && needs.parse-comment.outputs.is-bypass-command != 'true'
runs-on: ubuntu-latest
outputs:
codeowners-groups: ${{ steps.analyze.outputs.codeowners-groups }}
codeowners-teams: ${{ steps.analyze.outputs.codeowners-teams }}
codeowners-individuals: ${{ steps.analyze.outputs.codeowners-individuals }}
changed-files: ${{ steps.analyze.outputs.changed-files }}
files-without-owners: ${{ steps.analyze.outputs.files-without-owners }}
total-additions: ${{ steps.get-files.outputs.total-additions }}
total-deletions: ${{ steps.get-files.outputs.total-deletions }}
changed-files-count: ${{ steps.get-files.outputs.changed-files-count }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
sparse-checkout: .github/CODEOWNERS
sparse-checkout-cone-mode: false
- name: Get PR changed files
id: get-files
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
# Get all changed files using GitHub API (handle pagination)
rm -f changed_files.txt changed_files_stats.txt
PAGE=1
TOTAL_FILES=0
TOTAL_ADDITIONS=0
TOTAL_DELETIONS=0
while true; do
FILES_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER/files?per_page=100&page=$PAGE"
FILES_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$FILES_API")
# Check if we got any data
if [ -z "$FILES_DATA" ] || [ "$FILES_DATA" = "null" ] || [ "$FILES_DATA" = "[]" ]; then
break
fi
# Extract filenames from this page and append to file
PAGE_FILES=$(echo "$FILES_DATA" | jq -r '.[].filename' 2>/dev/null)
if [ -z "$PAGE_FILES" ] || [ "$PAGE_FILES" = "null" ]; then
break
fi
# Count files in this page
PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l)
TOTAL_FILES=$((TOTAL_FILES + PAGE_COUNT))
# Append filenames to file
echo "$PAGE_FILES" >> changed_files.txt
# Extract and sum additions and deletions from this page
PAGE_ADDITIONS=$(echo "$FILES_DATA" | jq -r '[.[].additions] | add' 2>/dev/null || echo 0)
PAGE_DELETIONS=$(echo "$FILES_DATA" | jq -r '[.[].deletions] | add' 2>/dev/null || echo 0)
TOTAL_ADDITIONS=$((TOTAL_ADDITIONS + PAGE_ADDITIONS))
TOTAL_DELETIONS=$((TOTAL_DELETIONS + PAGE_DELETIONS))
PAGE=$((PAGE + 1))
# Safety check to avoid infinite loop
if [ $PAGE -gt 10 ]; then
echo "Warning: Too many pages, stopping at page $PAGE"
break
fi
done
# Sort, remove duplicates and empty lines
if [ -f changed_files.txt ]; then
sort changed_files.txt | uniq | sed '/^$/d' > changed_files_sorted.txt
mv changed_files_sorted.txt changed_files.txt
fi
# Count files and show first few
FILE_COUNT=$(wc -l < changed_files.txt 2>/dev/null || echo 0)
echo "Found $FILE_COUNT changed files across $((PAGE-1)) pages (total from API: $TOTAL_FILES)"
echo "First 10 files:"
head -10 changed_files.txt
# Save files list for next step
{
echo "changed-files<<EOF"
cat changed_files.txt
echo "EOF"
echo "total-additions=$TOTAL_ADDITIONS"
echo "total-deletions=$TOTAL_DELETIONS"
echo "changed-files-count=$FILE_COUNT"
} >> $GITHUB_OUTPUT
- name: Install codeowners package and analyze
id: analyze
run: |
# Install required package
pip install codeowners
# Embedded Python script for CODEOWNERS analysis
python3 - changed_files.txt .github/CODEOWNERS << 'EOF'
#!/usr/bin/env python3
"""
CodeOwners Analysis Script - Embedded Version
"""
import os
import sys
import json
import urllib.request
from urllib.error import HTTPError
# Import codeowners package (required)
try:
from codeowners import CodeOwners
except ImportError:
print("Error: codeowners package is required but not installed.")
print("Install it with: pip install codeowners")
sys.exit(1)
def get_user_full_name(username):
"""Get full name for a GitHub username using GitHub API."""
if not username or not username.startswith("@"):
return username
# Remove @ prefix for API call
clean_username = username[1:] # Remove @
# Skip API calls for team names (containing /)
if "/" in clean_username:
return clean_username
# Try both tokens
token = (
os.environ.get("GITHUB_TOKEN") or os.environ.get("ORG_READ_GITHUB_TOKEN") or os.environ.get("ORG_READ") or ""
)
if not token:
print(f"Warning: No token available for user lookup of {username}")
return clean_username
try:
# Use GitHub API to get user information
url = f"https://api.github.com/users/{clean_username}"
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Accept", "application/vnd.github.v3+json")
req.add_header("User-Agent", "GitHub-Actions-CodeOwners-Analysis")
with urllib.request.urlopen(req) as response:
if response.getcode() == 200:
user_data = json.loads(response.read().decode())
return user_data.get("name") or clean_username
else:
print(f"Warning: API returned {response.getcode()} for user {username}")
return clean_username
except HTTPError as e:
if e.code == 401:
print(f"Warning: Unauthorized access for user {username} (insufficient token permissions)")
elif e.code == 403:
print(f"Warning: Forbidden access for user {username} (token lacks user:read scope)")
elif e.code == 404:
print(f"Warning: User {username} not found")
else:
print(f"Warning: HTTP error {e.code} for user {username}")
return clean_username
except Exception as e:
print(f"Warning: Error getting name for {username}: {e}")
return clean_username
def analyze_codeowners(changed_files_path, codeowners_path):
"""Analyze CODEOWNERS file and return required groups using codeowners package."""
# Read changed files
with open(changed_files_path, "r") as f:
changed_files = [line.strip() for line in f if line.strip()]
print(f"Analyzing {len(changed_files)} changed files using codeowners package...")
# Use codeowners package - read file content first
with open(codeowners_path, "r") as f:
codeowners_content = f.read()
co = CodeOwners(codeowners_content)
# Parse CODEOWNERS file - find all unique patterns that match changed files
# and collect their owners and associated files
pattern_groups = {} # pattern -> set of (username, full_name) tuples
pattern_files = {} # pattern -> list of files
team_groups = set()
team_patterns = {} # team -> list of patterns (for aggregating files)
processed_patterns = set() # Track patterns we've already processed
files_without_owners = [] # Track files that have no codeowners
# First pass: find all unique patterns that match any changed files
for file_path in changed_files:
matching_lines = list(co.matching_lines(file_path))
if matching_lines:
# GitHub CODEOWNERS precedence: last matching pattern takes precedence
# Sort by line number (highest first) and use only the most specific match
sorted_matches = sorted(matching_lines, key=lambda x: x[1], reverse=True)
best_match = sorted_matches[0]
print(
f"Found {len(matching_lines)} matching lines for {file_path}, using most specific (line {best_match[1]})"
)
# Use only the owners from the most specific match
if len(best_match) >= 3:
owners_list = best_match[0] # First element is the owners list
pattern = best_match[2] # Third element is the pattern
# Initialize pattern data structures if this is the first time seeing this pattern
if pattern not in pattern_groups:
pattern_groups[pattern] = set()
pattern_files[pattern] = []
# Always add this file to the pattern's file list
pattern_files[pattern].append(file_path)
# Only process owners if we haven't seen this pattern before
if pattern not in processed_patterns:
processed_patterns.add(pattern)
for owner_type, owner in owners_list:
if owner_type == "TEAM":
# This is a team
team_groups.add(owner)
# Track which patterns this team owns
if owner not in team_patterns:
team_patterns[owner] = []
team_patterns[owner].append(pattern)
elif owner_type in ["USERNAME", "EMAIL"]:
# This is an individual - get full name and store both username and full name
full_name = get_user_full_name(owner)
# Store as tuple: (username, full_name)
username = owner[1:] if owner.startswith("@") else owner # Remove @ prefix if present
pattern_groups[pattern].add((username, full_name))
else:
print(f"No matches found for {file_path}")
files_without_owners.append(file_path)
# Create output - teams are separate, individuals are grouped by pattern
teams_list = []
for team in sorted(team_groups):
# Aggregate files from all patterns owned by this team
team_files = set()
for pattern in team_patterns.get(team, []):
team_files.update(pattern_files.get(pattern, []))
team_files_str = ",".join(sorted(team_files))
# Keep the @ prefix in team name for consistency
teams_list.append(f"{team}:{team_files_str}")
teams_list = "§".join(teams_list)
# For individuals, we need to group them by their patterns
# Each pattern becomes a "group" that requires approval from any of its members
pattern_groups_list = []
for pattern, owners in pattern_groups.items():
if owners: # Only include patterns that have individuals
# Format: pattern:username1|full_name1,username2|full_name2,...:file1,file2,file3
owners_pairs = []
for username, full_name in sorted(owners, key=lambda x: x[1]): # Sort by full name
owners_pairs.append(f"{username}|{full_name}")
owners_str = ",".join(owners_pairs)
# Get files for this pattern
files_list = pattern_files.get(pattern, [])
files_str = ",".join(files_list)
pattern_groups_list.append(f"{pattern}:{owners_str}:{files_str}")
individuals_list = "§".join(pattern_groups_list) if pattern_groups_list else ""
# Combine all groups - now using § as delimiter for both teams and individuals
if teams_list and individuals_list:
all_groups = teams_list + "§" + individuals_list
elif teams_list:
all_groups = teams_list
else:
all_groups = individuals_list
print(f"Found {len(team_groups)} team groups and {len(pattern_groups)} pattern groups")
print(f"Teams: {teams_list}")
print(f"Individuals: {individuals_list}")
print(f"Files without owners: {len(files_without_owners)}")
return {
"all_groups": all_groups,
"teams": teams_list,
"individuals": individuals_list,
"changed_files": "\n".join(changed_files),
"files_without_owners": "\n".join(files_without_owners) if files_without_owners else "",
}
def main():
"""Main entry point when run as script."""
if len(sys.argv) != 3:
print("Usage: python codeowners_analysis.py <changed_files.txt> <codeowners_path>")
sys.exit(1)
changed_files_path = sys.argv[1]
codeowners_path = sys.argv[2]
if not os.path.exists(changed_files_path):
print(f"Error: Changed files path '{changed_files_path}' does not exist")
sys.exit(1)
if not os.path.exists(codeowners_path):
print(f"Error: CODEOWNERS path '{codeowners_path}' does not exist")
sys.exit(1)
result = analyze_codeowners(changed_files_path, codeowners_path)
# Output in GitHub Actions format if GITHUB_OUTPUT is available
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
# Clear the file first, then write
with open(github_output, "w") as f:
f.write("changed-files<<EOF\n")
f.write(result["changed_files"])
f.write("\nEOF\n")
f.write("codeowners-groups<<EOF\n")
f.write(result["all_groups"])
f.write("\nEOF\n")
f.write("codeowners-teams<<EOF\n")
f.write(result["teams"])
f.write("\nEOF\n")
f.write("codeowners-individuals<<EOF\n")
f.write(result["individuals"])
f.write("\nEOF\n")
f.write("files-without-owners<<EOF\n")
f.write(result["files_without_owners"])
f.write("\nEOF\n")
else:
# When run outside GitHub Actions, print results to stdout
print("\n" + "=" * 50)
print("RESULTS (for GitHub Actions):")
print("=" * 50)
print(f"changed-files={repr(result['changed_files'])}")
print(f"codeowners-groups={repr(result['all_groups'])}")
print(f"codeowners-teams={repr(result['teams'])}")
print(f"codeowners-individuals={repr(result['individuals'])}")
print("=" * 50)
if __name__ == "__main__":
main()
EOF
env:
GITHUB_TOKEN: ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}
ORG_READ_GITHUB_TOKEN: ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}
post-comment:
needs: [find-pr, analyze-codeowners, get-reviews, parse-comment]
if: always() && needs.find-pr.outputs.pr-exists == 'true' && needs.parse-comment.outputs.is-bypass-command != 'true'
runs-on: ubuntu-latest
steps:
- name: Generate comment data
id: generate-comment
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
# Handle cases where analyze-codeowners job might be skipped (direct ping)
TEAMS="${{ needs.analyze-codeowners.outputs.codeowners-teams || '' }}"
INDIVIDUALS="${{ needs.analyze-codeowners.outputs.codeowners-individuals || '' }}"
FILES_WITHOUT_OWNERS="${{ needs.analyze-codeowners.outputs.files-without-owners || '' }}"
APPROVED_REVIEWERS="${{ needs.get-reviews.outputs.approved-reviewers }}"
TOTAL_ADDITIONS="${{ needs.analyze-codeowners.outputs.total-additions || 0 }}"
TOTAL_DELETIONS="${{ needs.analyze-codeowners.outputs.total-deletions || 0 }}"
CHANGED_FILES_COUNT="${{ needs.analyze-codeowners.outputs.changed-files-count || 0 }}"
# Determine parameters based on trigger type
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CREATE_NEW="${{ github.event.inputs.create-new-comment }}"
PING_OWNERS="${{ github.event.inputs.ping-pending-owners }}"
SEND_SLACK="${{ github.event.inputs.send-slack-notification }}"
AUTHOR_NOTES="${{ github.event.inputs.author-notes }}"
else
# Comment trigger - use parsed parameters
CREATE_NEW="${{ needs.parse-comment.outputs.create-new-comment }}"
PING_OWNERS="${{ needs.parse-comment.outputs.ping-pending-owners }}"
SEND_SLACK="${{ needs.parse-comment.outputs.send-slack-notification }}"
AUTHOR_NOTES="${{ needs.parse-comment.outputs.author-notes }}"
fi
# Use #tt-metal-pr-review-requests Slack channel
SLACK_CHANNEL="C07G47JMQHM"
echo "Using parameters: CREATE_NEW=$CREATE_NEW, PING_OWNERS=$PING_OWNERS, SEND_SLACK=$SEND_SLACK, SLACK_CHANNEL=$SLACK_CHANNEL"
# Check for large PRs that might cause processing issues
if [ "$CHANGED_FILES_COUNT" -gt 200 ]; then
echo "⚠️ WARNING: Large PR detected ($CHANGED_FILES_COUNT files). Comment generation may be slow."
fi
TEAM_MEMBERS=""
if [ -n "$TEAMS" ]; then
IFS='§' read -ra TEAM_ARRAY <<< "$TEAMS"
for team_entry in "${TEAM_ARRAY[@]}"; do
team=$(echo "$team_entry" | cut -d':' -f1)
clean_team=$(echo "$team" | sed 's/^@[^\/]*\///')
MEMBERS_API="https://api.github.com/orgs/tenstorrent/teams/$clean_team/members"
MEMBERS_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" -H "Accept: application/vnd.github.v3+json" "$MEMBERS_API")
if [ "$MEMBERS_HTTP_CODE" = "200" ]; then
MEMBERS_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" -H "Accept: application/vnd.github.v3+json" "$MEMBERS_API" 2>/dev/null)
TEAM_MEMBER_LOGINS=$(echo "$MEMBERS_DATA" | jq -r '.[].login' 2>/dev/null | tr '\n' ',' | sed 's/,$//')
[ -n "$TEAM_MEMBER_LOGINS" ] && TEAM_MEMBERS="$TEAM_MEMBERS$team:$TEAM_MEMBER_LOGINS|" || TEAM_MEMBERS="$TEAM_MEMBERS$team:no-members|"
elif [ "$MEMBERS_HTTP_CODE" = "404" ]; then
TEAM_MEMBERS="$TEAM_MEMBERS$team:team-not-found|"
elif [ "$MEMBERS_HTTP_CODE" = "403" ]; then
TEAM_MEMBERS="$TEAM_MEMBERS$team:insufficient-permissions|"
elif [ "$MEMBERS_HTTP_CODE" = "401" ]; then
TEAM_MEMBERS="$TEAM_MEMBERS$team:unauthorized|"
else
TEAM_MEMBERS="$TEAM_MEMBERS$team:api-error|"
fi
done
fi
echo "$TEAM_MEMBERS" > "${RUNNER_TEMP}/team_members.txt"
declare -A GROUP_TYPE GROUP_NAME GROUP_MEMBERS_INFO GROUP_MEMBERS_LIST GROUP_FILES GROUP_FILES_HTML
declare -A GROUP_APPROVED_BY GROUP_IS_APPROVED
GROUP_KEYS=()
GROUP_INDEX=0
if [ -n "$TEAMS" ]; then
IFS='§' read -ra TEAM_ARRAY <<< "$TEAMS"
for team_entry in "${TEAM_ARRAY[@]}"; do
[ -z "$team_entry" ] && continue
team=$(echo "$team_entry" | cut -d':' -f1)
[ -z "$team" ] && continue
[ "$team" = "@tenstorrent/codeowner-bypass" ] && continue
team_files=$(echo "$team_entry" | cut -d':' -f2-)
clean_team=$(echo "$team" | sed 's/^@//')
sorted_files=$(echo "$team_files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
KEY="team_$GROUP_INDEX"
GROUP_KEYS+=("$KEY")
GROUP_TYPE["$KEY"]="team"
GROUP_NAME["$KEY"]="$clean_team"
GROUP_FILES["$KEY"]="$sorted_files"
team_members_info=""
team_members_list=""
if [ -n "$TEAM_MEMBERS" ]; then
team_members_entry=$(echo "$TEAM_MEMBERS" | tr '|' '\n' | grep "^$team:" | head -1)
if [ -n "$team_members_entry" ]; then
team_owners=$(echo "$team_members_entry" | cut -d':' -f2)
if [ "$team_owners" = "insufficient-permissions" ]; then
team_members_info="Members: (insufficient token permissions)"
elif [ "$team_owners" = "team-not-found" ]; then
team_members_info="Members: (team not found)"
elif [ "$team_owners" = "unauthorized" ]; then
team_members_info="Members: (unauthorized access)"
elif [ "$team_owners" = "api-error" ]; then
team_members_info="Members: (API error)"
elif [ "$team_owners" = "no-members" ]; then
team_members_info="Members: (no members)"
else
all_members=""
IFS=',' read -ra MEMBERS_ARRAY <<< "$team_owners"
for member in "${MEMBERS_ARRAY[@]}"; do
member_name="$member"
if [ -n "$member" ] && [ "$member" != " " ]; then
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" -H "Accept: application/vnd.github.v3+json" "https://api.github.com/users/$member" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
[ -n "$USER_NAME" ] && [ "$USER_NAME" != "null" ] && member_name="$USER_NAME"
fi
all_members="$all_members$member_name, "
done
team_members_info="Members: $(echo "$all_members" | sed 's/, $//')"
team_members_list="$team_owners"
fi
else
team_members_info="Members: (team not found)"
fi
else
team_members_info="Members: (no team data)"
fi
GROUP_MEMBERS_INFO["$KEY"]="$team_members_info"
GROUP_MEMBERS_LIST["$KEY"]="$team_members_list"
file_list_html=""
if [ -n "$team_files" ] && [ "$team_files" != "null" ]; then
# Count total files for this team
team_file_count=$(echo "$team_files" | tr ',' '\n' | wc -l)
file_list_html="\n <details><summary>&#128193; Files owned by this team ($team_file_count files)</summary>\n\n"
IFS=',' read -ra TEAM_FILES_ARRAY <<< "$team_files"
for file in "${TEAM_FILES_ARRAY[@]}"; do
if [ -n "$file" ]; then
escaped_file=$(echo "$file" | sed 's/\*/\\*/g' | sed 's/_/\\_/g')
file_hash=$(echo -n "$file" | sha256sum | cut -d' ' -f1)
file_list_html="$file_list_html - [<code>$escaped_file</code>](${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER/files#diff-$file_hash)\n"
fi
done
file_list_html="$file_list_html\n </details>"
fi
GROUP_FILES_HTML["$KEY"]="$file_list_html"
GROUP_INDEX=$((GROUP_INDEX + 1))
done
fi
if [ -n "$INDIVIDUALS" ]; then
IFS='§' read -ra PATTERN_GROUPS <<< "$INDIVIDUALS"
for pattern_group in "${PATTERN_GROUPS[@]}"; do
[ -z "$pattern_group" ] && continue
files=$(echo "$pattern_group" | rev | cut -d':' -f1 | rev)
pattern_and_owners=$(echo "$pattern_group" | rev | cut -d':' -f2- | rev)
pattern=$(echo "$pattern_and_owners" | cut -d':' -f1)
owners=$(echo "$pattern_and_owners" | cut -d':' -f2-)
sorted_files=$(echo "$files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
KEY="pattern_$GROUP_INDEX"
GROUP_KEYS+=("$KEY")
GROUP_TYPE["$KEY"]="pattern"
GROUP_NAME["$KEY"]="$pattern"
GROUP_FILES["$KEY"]="$sorted_files"
all_owners="" usernames_list=""
IFS=',' read -ra OWNERS_ARRAY <<< "$owners"
for owner_pair in "${OWNERS_ARRAY[@]}"; do
username=$(echo "$owner_pair" | cut -d'|' -f1)
full_name=$(echo "$owner_pair" | cut -d'|' -f2)
all_owners="$all_owners$full_name, "
usernames_list="$usernames_list$username,"
done
GROUP_MEMBERS_INFO["$KEY"]="Members: $(echo "$all_owners" | sed 's/, $//')"
GROUP_MEMBERS_LIST["$KEY"]="$(echo "$usernames_list" | sed 's/,$//')"
file_list_html=""
if [ -n "$files" ] && [ "$files" != "null" ]; then
# Count total files for this group
group_file_count=$(echo "$files" | tr ',' '\n' | wc -l)
file_list_html="\n <details><summary>&#128193; Files owned by this group ($group_file_count files)</summary>\n\n"
IFS=',' read -ra FILES_ARRAY <<< "$files"
for file in "${FILES_ARRAY[@]}"; do
[ -n "$file" ] && file_list_html="$file_list_html - [<code>$(echo "$file" | sed 's/\*/\\*/g' | sed 's/_/\\_/g')</code>](${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER/files#diff-$(echo -n "$file" | sha256sum | cut -d' ' -f1))\n"
done
file_list_html="$file_list_html\n </details>"
fi
GROUP_FILES_HTML["$KEY"]="$file_list_html"
GROUP_INDEX=$((GROUP_INDEX + 1))
done
fi
# Helper function to check if two file lists have any overlap
files_have_overlap() {
local files1="$1"
local files2="$2"
if [ -z "$files1" ] || [ -z "$files2" ]; then
return 1
fi
# Convert comma-separated lists to arrays and check for any common file
IFS=',' read -ra FILES1_ARRAY <<< "$files1"
IFS=',' read -ra FILES2_ARRAY <<< "$files2"
for file1 in "${FILES1_ARRAY[@]}"; do
for file2 in "${FILES2_ARRAY[@]}"; do
if [ "$file1" = "$file2" ]; then
return 0 # Found overlap
fi
done
done
return 1 # No overlap
}
for key in "${GROUP_KEYS[@]}"; do
files="${GROUP_FILES[$key]}"
members="${GROUP_MEMBERS_LIST[$key]}"
combined_members="$members"
has_other_groups=false
for other_key in "${GROUP_KEYS[@]}"; do
if [ "$other_key" != "$key" ] && files_have_overlap "$files" "${GROUP_FILES[$other_key]}"; then
other_members="${GROUP_MEMBERS_LIST[$other_key]}"
[ -n "$other_members" ] && combined_members="$combined_members,$other_members" && has_other_groups=true
fi
done
direct_approved="" shared_approved=""
if [ -n "$combined_members" ]; then
IFS=',' read -ra COMBINED_ARRAY <<< "$combined_members"
for member in "${COMBINED_ARRAY[@]}"; do
[ -z "$member" ] && continue
if echo "$APPROVED_REVIEWERS" | grep -q "$member"; then
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" -H "Accept: application/vnd.github.v3+json" "https://api.github.com/users/$member" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
[ -z "$USER_NAME" ] || [ "$USER_NAME" = "null" ] && USER_NAME="$member"
if [ "$has_other_groups" = true ] && ! echo "$members" | grep -q "$member"; then
shared_approved="$shared_approved$USER_NAME, "
else
direct_approved="$direct_approved$USER_NAME, "
fi
fi
done
fi
direct_approved=$(echo "$direct_approved" | sed 's/, $//')
shared_approved=$(echo "$shared_approved" | sed 's/, $//')
approved_names=""
[ -n "$direct_approved" ] && [ -n "$shared_approved" ] && approved_names="$direct_approved, $shared_approved (shared)"
[ -n "$direct_approved" ] && [ -z "$shared_approved" ] && approved_names="$direct_approved"
[ -z "$direct_approved" ] && [ -n "$shared_approved" ] && approved_names="$shared_approved (shared)"
[ -n "$approved_names" ] && GROUP_IS_APPROVED["$key"]="true" && GROUP_APPROVED_BY["$key"]="$approved_names" || GROUP_IS_APPROVED["$key"]="false"
done
PENDING_GROUPS="" APPROVED_GROUPS="" PENDING_COUNT=0 APPROVED_COUNT=0
for key in "${GROUP_KEYS[@]}"; do
group_type="${GROUP_TYPE[$key]}"
group_name="${GROUP_NAME[$key]}"
members_info="${GROUP_MEMBERS_INFO[$key]}"
file_list_html="${GROUP_FILES_HTML[$key]}"
is_approved="${GROUP_IS_APPROVED[$key]}"
approved_by="${GROUP_APPROVED_BY[$key]}"
[ "$group_type" = "team" ] && type_label="Team" || type_label="Group"
if [ "$is_approved" = "true" ]; then
[ -n "$APPROVED_GROUPS" ] && APPROVED_GROUPS="$APPROVED_GROUPS\n---\n\n"
APPROVED_GROUPS="$APPROVED_GROUPS- ✅ **$group_name** ($type_label) - $members_info | **Approved by:** $approved_by$file_list_html"
APPROVED_COUNT=$((APPROVED_COUNT + 1))
else
[ -n "$PENDING_GROUPS" ] && PENDING_GROUPS="$PENDING_GROUPS\n---\n\n"
PENDING_GROUPS="$PENDING_GROUPS- ⏳ **$group_name** ($type_label) - $members_info | **Pending approval**$file_list_html"
PENDING_COUNT=$((PENDING_COUNT + 1))
fi
done
if [ -z "$TEAMS" ] && [ -z "$INDIVIDUALS" ]; then
COMMENT="<!-- CodeOwners-Workflow-Bot -->\n## CodeOwners Analysis\n\nNo specific codeowners found for the changed files in this PR.\n\n**Required Approvals:** At least one maintainer approval is required."
else
ALL_GROUPS="$PENDING_GROUPS"
[ -n "$PENDING_GROUPS" ] && [ -n "$APPROVED_GROUPS" ] && ALL_GROUPS="$ALL_GROUPS\n---\n\n$APPROVED_GROUPS"
[ -z "$PENDING_GROUPS" ] && [ -n "$APPROVED_GROUPS" ] && ALL_GROUPS="$APPROVED_GROUPS"
if [ "$PENDING_COUNT" -eq 0 ]; then
STATUS_MESSAGE="🎉 ✅ **All CodeOwners approvals complete!** This PR is ready to be merged."
SUMMARY_LINE="**Summary:** $APPROVED_COUNT/$APPROVED_COUNT groups approved"
else
STATUS_MESSAGE="This PR requires approval from **one member of each** of the following groups:"
SUMMARY_LINE="**Summary:** $PENDING_COUNT pending groups, $APPROVED_COUNT approved groups"
fi
COMMENT="<!-- CodeOwners-Workflow-Bot -->\n## CodeOwners Group Analysis\n\n$STATUS_MESSAGE\n\n$SUMMARY_LINE\n\n### Group Information:\n\n$ALL_GROUPS\n\n**Note:** At least one approval from each group is sufficient."
fi
[ "$NO_OWNERS_AVAILABLE" = "true" ] && COMMENT="$COMMENT\n\n**Note:** Only the PR author is available as a codeowner for this PR. No external review notifications will be sent."
[ "${{ github.event_name }}" = "issue_comment" ] && [ "$PING_OWNERS" = "false" ] && [ "$SEND_SLACK" = "false" ] && COMMENT="$COMMENT\n\n---\n💡 **Tip:** Use \\\`/codeowners ping\\\` to notify pending owners via Slack, or \\\`/ping @username1 @username2 @team/subteam\\\` to ping specific users and teams."
if [ -n "$FILES_WITHOUT_OWNERS" ]; then
FILE_COUNT=$(echo "$FILES_WITHOUT_OWNERS" | wc -l)
COMMENT="$COMMENT\n\n---\n\n### ⚠️ Files Without CodeOwners\n\n$FILE_COUNT files have no designated code owners."
# Limit the number of files shown to avoid comment size limits
if [ "$FILE_COUNT" -le 20 ]; then
FILES_WITHOUT_OWNERS_LIST=""
IFS=$'\n' read -ra FILES_ARRAY <<< "$FILES_WITHOUT_OWNERS"
for file in "${FILES_ARRAY[@]}"; do
[ -n "$file" ] && FILES_WITHOUT_OWNERS_LIST="$FILES_WITHOUT_OWNERS_LIST\n- [<code>$(echo "$file" | sed 's/\*/\\*/g' | sed 's/_/\\_/g')</code>](${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER/files#diff-$(echo -n "$file" | sha256sum | cut -d' ' -f1))"
done
COMMENT="$COMMENT$FILES_WITHOUT_OWNERS_LIST"
else
COMMENT="$COMMENT\n\n*(Showing first 20 files only)*"
FILES_WITHOUT_OWNERS_LIST=""
i=0
IFS=$'\n' read -ra FILES_ARRAY <<< "$FILES_WITHOUT_OWNERS"
for file in "${FILES_ARRAY[@]}"; do
if [ $i -ge 20 ]; then break; fi
[ -n "$file" ] && FILES_WITHOUT_OWNERS_LIST="$FILES_WITHOUT_OWNERS_LIST\n- [<code>$(echo "$file" | sed 's/\*/\\*/g' | sed 's/_/\\_/g')</code>](${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER/files#diff-$(echo -n "$file" | sha256sum | cut -d' ' -f1))"
i=$((i + 1))
done
COMMENT="$COMMENT$FILES_WITHOUT_OWNERS_LIST"
fi
COMMENT="$COMMENT\n\n💡 **Suggestion:** Consider adding these files to the CODEOWNERS file for better review coverage."
fi
# Output comment for next step (use temp file for large comments)
COMMENT_FILE=$(mktemp)
printf '%b\n' "$COMMENT" > "$COMMENT_FILE"
{
echo "comment<<EOF"
cat "$COMMENT_FILE"
echo "EOF"
} >> $GITHUB_OUTPUT
rm -f "$COMMENT_FILE"
# Output other data for next steps
{
echo "pr-number<<EOF"
echo "$PR_NUMBER"
echo "EOF"
echo "create-new<<EOF"
echo "$CREATE_NEW"
echo "EOF"
echo "ping-owners<<EOF"
echo "$PING_OWNERS"
echo "EOF"
echo "send-slack<<EOF"
echo "$SEND_SLACK"
echo "EOF"
echo "author-notes<<EOF"
echo "$AUTHOR_NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Post comment to PR
run: |
PR_NUMBER="${{ steps.generate-comment.outputs.pr-number }}"
COMMENT="${{ steps.generate-comment.outputs.comment }}"
CREATE_NEW="${{ steps.generate-comment.outputs.create-new }}"
# Get existing comments to find if we should edit or create new (with pagination)
COMMENTS_API="https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
ALL_COMMENTS="[]"
PAGE=1
echo "Fetching existing comments..."
while true; do
COMMENTS_PAGE=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$COMMENTS_API?per_page=100&page=$PAGE")
# Check if we got any data
if [ -z "$COMMENTS_PAGE" ] || [ "$COMMENTS_PAGE" = "null" ] || [ "$COMMENTS_PAGE" = "[]" ]; then
break
fi
# Merge this page with existing comments
ALL_COMMENTS=$(echo "$ALL_COMMENTS $COMMENTS_PAGE" | jq -s '.[0] + .[1]')
# Check if we got a full page (100 items), if not, we're done
PAGE_COUNT=$(echo "$COMMENTS_PAGE" | jq '. | length')
if [ "$PAGE_COUNT" -lt 100 ]; then
break
fi
PAGE=$((PAGE + 1))
# Safety check to avoid infinite loop
if [ $PAGE -gt 10 ]; then
echo "Warning: Too many comment pages, stopping at page $PAGE"
break
fi
done
TOTAL_COMMENTS=$(echo "$ALL_COMMENTS" | jq '. | length')
echo "Found $TOTAL_COMMENTS total comments across $((PAGE)) pages"
# Find existing comment from this workflow (contains our marker)
# Get the most recent one by sorting by created_at descending
EXISTING_COMMENT_ID=$(echo "$ALL_COMMENTS" | jq -r "[.[] | select(.body | contains(\"CodeOwners-Workflow-Bot\"))] | sort_by(.created_at) | reverse | .[0].id")
# Use jq to properly escape the comment for JSON (use temp file for large comments)
TEMP_JSON_FILE=$(mktemp)
COMMENT_FILE=$(mktemp)
echo "$COMMENT" > "$COMMENT_FILE"
jq -n --rawfile body "$COMMENT_FILE" '{"body": $body}' > "$TEMP_JSON_FILE"
rm -f "$COMMENT_FILE"
if [ "$CREATE_NEW" = "true" ]; then
# Create new comment
echo "Creating new comment..."
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"$COMMENTS_API"
elif [ -z "$EXISTING_COMMENT_ID" ] || [ "$EXISTING_COMMENT_ID" = "null" ]; then
# No existing comment found - create new for all commands except explicit "new"
if [ "$CREATE_NEW" = "false" ]; then
echo "No existing CodeOwners analysis found. Creating new summary comment..."
# Create new comment for bare commands and ping commands
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"$COMMENTS_API"
echo "Created new CodeOwners summary comment"
else
echo "No existing comment found and CREATE_NEW=true - this shouldn't happen"
fi
else
# Edit existing comment
echo "Updating existing comment (ID: $EXISTING_COMMENT_ID)..."
curl -s -X PATCH \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"https://api.github.com/repos/${{ github.repository }}/issues/comments/$EXISTING_COMMENT_ID"
fi
rm -f "$TEMP_JSON_FILE"
echo "Comment updated for PR #$PR_NUMBER"
# If we updated an existing comment, post an acknowledgment (but not for manual workflow runs)
if [ "$CREATE_NEW" = "false" ] && [ -n "$EXISTING_COMMENT_ID" ] && [ "$EXISTING_COMMENT_ID" != "null" ] && [ "${{ github.event_name }}" != "workflow_dispatch" ]; then
echo "Posting acknowledgment comment for updated summary..."
# Create URL to the updated comment
REPO_NAME="${{ github.repository }}"
COMMENT_URL="https://github.com/$REPO_NAME/pull/$PR_NUMBER#issuecomment-$EXISTING_COMMENT_ID"
# Create acknowledgment message
ACK_COMMENT=$(printf "## 🔄 CodeOwners Summary Updated\n\n✅ **CodeOwners summary updated** [here](%s)\n\n💡 **Tip:** Use \`/codeowners new\` to post a fresh summary comment instead of updating the existing one." "$COMMENT_URL")
# Create JSON payload for acknowledgment comment
ACK_JSON_FILE=$(mktemp)
jq -n --arg body "$ACK_COMMENT" '{"body": $body}' > "$ACK_JSON_FILE"
# Post acknowledgment comment
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$ACK_JSON_FILE" \
"$COMMENTS_API"
rm -f "$ACK_JSON_FILE"
echo "Acknowledgment comment posted for updated summary"
fi
- name: Fetch Slack users for direct ping
if: needs.parse-comment.outputs.is-direct-ping == 'true'
run: |
# Fetch Slack users securely (using temp file to avoid ARG_MAX limits)
echo "Fetching all Slack users..."
TEMP_USERS_FILE=$(mktemp)
echo "[]" > "$TEMP_USERS_FILE" # Initialize with empty array
CURSOR=""
PAGE_COUNT=0
while true; do
if [ -n "$CURSOR" ]; then
API_URL="https://slack.com/api/users.list?limit=1000&cursor=$CURSOR"
else
API_URL="https://slack.com/api/users.list?limit=1000"
fi
USER_SEARCH_RESPONSE=$(curl -s -X GET \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_PING_BOT }}" \
-H "Content-Type: application/json" \
"$API_URL")
if [ "$(echo "$USER_SEARCH_RESPONSE" | jq -r '.ok')" = "true" ]; then
USERS_PAGE=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.members')
# Merge arrays using temp files to avoid ARG_MAX limits
echo "$USERS_PAGE" > "${TEMP_USERS_FILE}.page"
jq -s '.[0] + .[1]' "$TEMP_USERS_FILE" "${TEMP_USERS_FILE}.page" > "${TEMP_USERS_FILE}.new"
mv "${TEMP_USERS_FILE}.new" "$TEMP_USERS_FILE"
rm -f "${TEMP_USERS_FILE}.page"
PAGE_COUNT=$((PAGE_COUNT + 1))
CURSOR=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.response_metadata.next_cursor // empty')
if [ -z "$CURSOR" ] || [ "$CURSOR" = "null" ]; then
break
fi
else
ERROR_MSG=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.error // "Unknown error"')
echo "Error: Failed to fetch Slack users: $ERROR_MSG"
break
fi
done
USER_COUNT=$(jq '. | length' "$TEMP_USERS_FILE")
echo "✅ Successfully fetched $USER_COUNT Slack users across $PAGE_COUNT pages"
# Move to RUNNER_TEMP for persistence between steps (avoids environment size limits)
mv "$TEMP_USERS_FILE" "${RUNNER_TEMP}/slack_users_direct.json"
echo "Saved Slack users to ${RUNNER_TEMP}/slack_users_direct.json"
- name: Send direct ping notification
if: needs.parse-comment.outputs.is-direct-ping == 'true'
run: |
PR_NUMBER="${{ needs.find-pr.outputs.pr-number }}"
DIRECT_PING_USERS="${{ needs.parse-comment.outputs.direct-ping-users }}"
AUTHOR_NOTES="${{ needs.parse-comment.outputs.author-notes }}"
TOTAL_ADDITIONS="${{ needs.analyze-codeowners.outputs.total-additions || 0 }}"
TOTAL_DELETIONS="${{ needs.analyze-codeowners.outputs.total-deletions || 0 }}"
REQUESTER_LOGIN="${{ needs.parse-comment.outputs.comment-author }}"
REQUESTER_NAME="$REQUESTER_LOGIN"
# Load Slack users from file (set in previous step)
ALL_SLACK_USERS=$(cat "${RUNNER_TEMP}/slack_users_direct.json")
USER_COUNT=$(echo "$ALL_SLACK_USERS" | jq '. | length')
echo "Loaded $USER_COUNT Slack users from cache"
# Get full name for requester if available
echo "DEBUG: REQUESTER_LOGIN='$REQUESTER_LOGIN', PR_AUTHOR_LOGIN='$PR_AUTHOR_LOGIN'"
if [ -n "$REQUESTER_LOGIN" ] && [ "$REQUESTER_LOGIN" != "$PR_AUTHOR_LOGIN" ]; then
echo "DEBUG: Requester and PR author are different users"
REQUESTER_USER_API="https://api.github.com/users/$REQUESTER_LOGIN"
REQUESTER_USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$REQUESTER_USER_API" 2>/dev/null)
REQUESTER_FULL_NAME=$(echo "$REQUESTER_USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$REQUESTER_FULL_NAME" ] && [ "$REQUESTER_FULL_NAME" != "null" ]; then
REQUESTER_NAME="$REQUESTER_FULL_NAME"
else
REQUESTER_NAME="$REQUESTER_LOGIN"
fi
else
echo "DEBUG: Requester and PR author are the same user (or REQUESTER_LOGIN is empty)"
fi
# Use #tt-metal-pr-review-requests Slack channel
SLACK_CHANNEL="C07G47JMQHM"
# GitHub team to Slack group mapping (needed for author notes processing)
get_slack_group_id() {
local github_team="$1"
case "$github_team" in
"@tenstorrent/metalium-developers-infra")
echo "S0985AN7TC5"
;;
"@tenstorrent/metalium-developers-ttnn-core" | "@tenstorrent/metalium-ttnn-core-team")
echo "S0988UJEW8K"
;;
"@tenstorrent/metalium-developers-convolutions")
echo "S09DNR6NAG4"
;;
"@tenstorrent/metalium-developers-ops-data-movement")
echo "S09QQRK1CF8"
;;
"@tenstorrent/metalium-developers-eltwise")
echo "S0ABKSS1D3R"
;;
*)
echo "" # No Slack group available
;;
esac
}
echo "Processing direct ping for users: $DIRECT_PING_USERS"
# Get PR information with error handling
PR_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER"
# Check API response status
PR_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
if [ "$PR_HTTP_CODE" != "200" ]; then
echo "❌ ERROR: Failed to fetch PR information (HTTP $PR_HTTP_CODE)"
echo "API URL: $PR_API"
exit 1
fi
PR_DATA=$(curl -s --max-time 30 -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
# Validate PR data
if [ -z "$PR_DATA" ] || [ "$PR_DATA" = "null" ]; then
echo "❌ ERROR: Empty PR data returned from API"
exit 1
fi
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR_LOGIN=$(echo "$PR_DATA" | jq -r '.user.login')
PR_AUTHOR_NAME=$(echo "$PR_DATA" | jq -r '.user.name // .user.login')
# Validate critical fields
if [ -z "$PR_TITLE" ] || [ "$PR_TITLE" = "null" ] || [ -z "$PR_AUTHOR_LOGIN" ] || [ "$PR_AUTHOR_LOGIN" = "null" ]; then
echo "❌ ERROR: Invalid PR data - missing title or author"
echo "PR Title: \"$PR_TITLE\""
echo "PR Author: $PR_AUTHOR_LOGIN"
exit 1
fi
# Get full name for PR author
PR_AUTHOR_FULL_NAME="$PR_AUTHOR_NAME"
if [ "$PR_AUTHOR_FULL_NAME" = "$PR_AUTHOR_LOGIN" ] || [ -z "$PR_AUTHOR_FULL_NAME" ] || [ "$PR_AUTHOR_FULL_NAME" = "null" ]; then
AUTHOR_USER_API="https://api.github.com/users/$PR_AUTHOR_LOGIN"
AUTHOR_USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$AUTHOR_USER_API" 2>/dev/null)
AUTHOR_FULL_NAME=$(echo "$AUTHOR_USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$AUTHOR_FULL_NAME" ] && [ "$AUTHOR_FULL_NAME" != "null" ]; then
PR_AUTHOR_FULL_NAME="$AUTHOR_FULL_NAME"
fi
fi
# Define the find_slack_user_id function (uses pre-fetched user list)
find_slack_user_id() {
local full_name="$1"
local github_username="$2"
local ALL_USERS="$3" # Pre-fetched user list passed as parameter
# Hardcoded mappings for users where fuzzy matching fails
case "$github_username" in
"mradosavljevicTT")
echo "U0837MYG788"
return 0
;;
"nsextonTT")
echo "U08TVGQGGAE"
return 0
;;
"ncvetkovicTT")
echo "U07AUABTEP6"
return 0
;;
"jvegaTT")
echo "U07M7QZ0BQA"
return 0
;;
esac
# Try exact matching first
USER_ID=$(echo "$ALL_USERS" | jq -r --arg name "$full_name" '.[] | select(.real_name == $name or .profile.real_name == $name) | .id' | head -n1)
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
USER_ID=$(echo "$ALL_USERS" | jq -r --arg name "$full_name" '.[] | select(.profile.display_name == $name) | .id' | head -n1)
fi
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
USER_ID=$(echo "$ALL_USERS" | jq -r --arg username "$github_username" '.[] | select(.name == $username or .profile.display_name == $username) | .id' | head -n1)
fi
# Smart word-by-word fuzzy matching if still not found
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
echo "Trying word-by-word fuzzy matching for: $full_name" >&2
IFS=' ' read -ra NAME_WORDS <<< "$full_name"
# Try each word individually - use the first word that has exactly one match
for word in "${NAME_WORDS[@]}"; do
if [ ${#word} -ge 3 ]; then
echo " Checking word: '$word'" >&2
# Find all users that match this word
WORD_MATCHES=$(echo "$ALL_USERS" | jq -r --arg word "$word" '.[] | select((.real_name // "" | ascii_downcase | contains($word | ascii_downcase)) or (.profile.real_name // "" | ascii_downcase | contains($word | ascii_downcase)) or (.profile.display_name // "" | ascii_downcase | contains($word | ascii_downcase))) | .id + "|" + (.real_name // .profile.real_name // .name)')
if [ -n "$WORD_MATCHES" ]; then
# Count unique matches for this word
MATCH_COUNT=$(echo "$WORD_MATCHES" | wc -l)
if [ "$MATCH_COUNT" -eq 1 ]; then
# Exactly one match - use it!
USER_ID=$(echo "$WORD_MATCHES" | cut -d'|' -f1)
MATCHED_NAME=$(echo "$WORD_MATCHES" | cut -d'|' -f2)
echo "✅ Found unique match for '$word': $full_name -> $MATCHED_NAME ($USER_ID)" >&2
break # Stop here, we found our match
else
echo " '$word' matches $MATCH_COUNT users, skipping" >&2
fi
else
echo " '$word' matches 0 users, skipping" >&2
fi
fi
done
# If no unique word match found, report it
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
echo "⚠️ No unique word matches found for: $full_name" >&2
fi
fi
if [ -n "$USER_ID" ] && [ "$USER_ID" != "null" ]; then
echo "$USER_ID"
else
echo ""
fi
}
# Find Slack user ID for PR author
echo "Looking up PR author in Slack: $PR_AUTHOR_FULL_NAME"
PR_AUTHOR_SLACK_ID=$(find_slack_user_id "$PR_AUTHOR_FULL_NAME" "$PR_AUTHOR_LOGIN" "$ALL_SLACK_USERS")
if [ -n "$PR_AUTHOR_SLACK_ID" ]; then
PR_AUTHOR_MENTION="<@$PR_AUTHOR_SLACK_ID>"
echo "✅ Found PR author in Slack: $PR_AUTHOR_FULL_NAME -> $PR_AUTHOR_SLACK_ID"
else
PR_AUTHOR_MENTION="$PR_AUTHOR_FULL_NAME (@$PR_AUTHOR_LOGIN)"
echo "⚠️ PR author not found in Slack: $PR_AUTHOR_FULL_NAME (@$PR_AUTHOR_LOGIN), using fallback"
fi
# Find Slack user ID for requester if different from author
REQUESTER_MENTION=""
if [ -n "$REQUESTER_LOGIN" ] && [ "$REQUESTER_LOGIN" != "$PR_AUTHOR_LOGIN" ]; then
echo "Looking up requester in Slack: $REQUESTER_NAME"
REQUESTER_SLACK_ID=$(find_slack_user_id "$REQUESTER_NAME" "$REQUESTER_LOGIN" "$ALL_SLACK_USERS")
if [ -n "$REQUESTER_SLACK_ID" ]; then
REQUESTER_MENTION="<@$REQUESTER_SLACK_ID>"
echo "✅ Found requester in Slack: $REQUESTER_NAME -> $REQUESTER_SLACK_ID"
else
REQUESTER_MENTION="$REQUESTER_NAME (@$REQUESTER_LOGIN)"
echo "⚠️ Requester not found in Slack: $REQUESTER_NAME (@$REQUESTER_LOGIN), using fallback"
fi
fi
# Process each user/team to ping
SLACK_USER_MENTIONS=""
if [ -n "$DIRECT_PING_USERS" ]; then
IFS=',' read -ra PING_ARRAY <<< "$DIRECT_PING_USERS"
for ping_item in "${PING_ARRAY[@]}"; do
if [ -n "$ping_item" ]; then
# Remove @ prefix
clean_item=$(echo "$ping_item" | sed 's/^@//')
# Check if this is a team (contains /) or individual user
if echo "$clean_item" | grep -q '/'; then
# This is a team - try to map to Slack group
SLACK_GROUP_ID=$(get_slack_group_id "$ping_item")
if [ -n "$SLACK_GROUP_ID" ]; then
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS <!subteam^$SLACK_GROUP_ID>"
echo "✅ Found Slack group for team $ping_item: $SLACK_GROUP_ID"
else
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS $clean_item"
echo "⚠️ No Slack group found for team: $ping_item, using team name fallback"
fi
else
# This is an individual user
# Get full name from GitHub API
USER_API="https://api.github.com/users/$clean_item"
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$USER_API" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "null" ]; then
USER_NAME="$clean_item"
fi
# Find Slack user ID
SLACK_USER_ID=$(find_slack_user_id "$USER_NAME" "$clean_item" "$ALL_SLACK_USERS")
if [ -n "$SLACK_USER_ID" ]; then
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS <@$SLACK_USER_ID>"
echo "✅ Found Slack user: $USER_NAME -> $SLACK_USER_ID"
else
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS $USER_NAME"
echo "⚠️ Slack user not found for: $USER_NAME (@$clean_item), using full name fallback"
fi
fi
fi
done
fi
# Build Slack message (same format as existing CodeOwners Review Request)
SLACK_MESSAGE="🔔 *CodeOwners Review Request*"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"Hi$SLACK_USER_MENTIONS!"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"📋 *PR:* <${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER|$PR_TITLE>"
# Add author and requested by information (combine on same line if different)
echo "DEBUG: Checking requested by - REQUESTER_LOGIN='$REQUESTER_LOGIN', PR_AUTHOR_LOGIN='$PR_AUTHOR_LOGIN'"
if [ -n "$REQUESTER_MENTION" ]; then
echo "DEBUG: Adding combined Author and Requested by line"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"👤 *Author:* $PR_AUTHOR_MENTION │ 🔄 *Requested by:* $REQUESTER_MENTION"
else
echo "DEBUG: Adding Author only (requester same as author or empty)"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"👤 *Author:* $PR_AUTHOR_MENTION"
fi
# Add changes count if available
echo "DEBUG: TOTAL_ADDITIONS='$TOTAL_ADDITIONS', TOTAL_DELETIONS='$TOTAL_DELETIONS'"
if [ -n "$TOTAL_ADDITIONS" ] && [ "$TOTAL_ADDITIONS" -gt 0 ] || [ -n "$TOTAL_DELETIONS" ] && [ "$TOTAL_DELETIONS" -gt 0 ]; then
echo "DEBUG: Adding changes count to Slack message"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"📊 *Changes:* -$TOTAL_DELETIONS +$TOTAL_ADDITIONS"
else
echo "DEBUG: Not adding changes count (no changes or empty values)"
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"🔍 *Action Required:* This PR needs your code review/approval to proceed with merging."
# Add author notes if provided
if [ -n "$AUTHOR_NOTES" ]; then
# Process GitHub usernames in author notes (same logic as before)
PROCESSED_NOTES="$AUTHOR_NOTES"
# Find all GitHub usernames in author notes (pattern: @username, but NOT @org/team)
# This regex matches @username but excludes @org/team-name patterns
GITHUB_USERNAMES=$(echo "$AUTHOR_NOTES" | grep -oE '@[a-zA-Z0-9_-]+' | grep -v '@.*/' | sort | uniq)
if [ -n "$GITHUB_USERNAMES" ]; then
echo "Found GitHub usernames in author notes: $GITHUB_USERNAMES"
for github_mention in $GITHUB_USERNAMES; do
if [ -n "$github_mention" ]; then
clean_username=$(echo "$github_mention" | sed 's/^@//')
# Skip if this looks like an organization name (common org names)
if [ "$clean_username" = "tenstorrent" ] || [ "$clean_username" = "github" ] || [ "$clean_username" = "microsoft" ]; then
echo "Skipping organization name: $github_mention"
continue
fi
USER_API="https://api.github.com/users/$clean_username"
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$USER_API" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "null" ]; then
USER_NAME="$clean_username"
fi
SLACK_USER_ID=$(find_slack_user_id "$USER_NAME" "$clean_username" "$ALL_SLACK_USERS")
if [ -n "$SLACK_USER_ID" ]; then
REPLACEMENT="<@$SLACK_USER_ID>"
echo "✅ Converting $github_mention to Slack mention: $USER_NAME -> $SLACK_USER_ID"
else
REPLACEMENT="@$USER_NAME"
echo "⚠️ Converting $github_mention to full name: $USER_NAME"
fi
ESCAPED_MENTION=$(echo "$github_mention" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_REPLACEMENT=$(echo "$REPLACEMENT" | sed 's/[[\.*^$()+?{|\\]/\\&/g')
PROCESSED_NOTES=$(echo "$PROCESSED_NOTES" | sed "s/$ESCAPED_MENTION/$ESCAPED_REPLACEMENT/g")
fi
done
AUTHOR_NOTES="$PROCESSED_NOTES"
fi
# Find GitHub team mentions and optionally convert to Slack groups
GITHUB_TEAMS=$(echo "$AUTHOR_NOTES" | grep -oE '@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+' | sort | uniq)
if [ -n "$GITHUB_TEAMS" ]; then
echo "Found GitHub team mentions: $GITHUB_TEAMS"
for team_mention in $GITHUB_TEAMS; do
# Check if this team has a corresponding Slack group
SLACK_GROUP_ID=$(get_slack_group_id "$team_mention")
if [ -n "$SLACK_GROUP_ID" ]; then
# Replace with Slack group mention
TEAM_REPLACEMENT="<!subteam^$SLACK_GROUP_ID>"
echo "✅ Converting team $team_mention to Slack group: $SLACK_GROUP_ID"
ESCAPED_TEAM=$(echo "$team_mention" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_TEAM_REPLACEMENT=$(echo "$TEAM_REPLACEMENT" | sed 's/[[\.*^$()+?{|\\]/\\&/g')
AUTHOR_NOTES=$(echo "$AUTHOR_NOTES" | sed "s/$ESCAPED_TEAM/$ESCAPED_TEAM_REPLACEMENT/g")
else
echo "⚠️ No Slack group found for team: $team_mention"
fi
done
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"💬 *Author Notes:* $AUTHOR_NOTES"
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"Please review when you have a moment. Thank you! 🚀"
# Send to Slack (use temp file for large messages)
echo "$SLACK_MESSAGE" > /tmp/slack_message.txt
SLACK_PAYLOAD=$(jq -n \
--arg channel "$SLACK_CHANNEL" \
--rawfile text /tmp/slack_message.txt \
'{
channel: $channel,
text: $text
}')
echo "Sending direct ping to Slack..."
SLACK_RESPONSE=$(curl -s --max-time 30 -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_PING_BOT }}" \
-H "Content-type: application/json" \
-d "$SLACK_PAYLOAD" \
"https://slack.com/api/chat.postMessage")
if [ "$(echo "$SLACK_RESPONSE" | jq -r '.ok')" = "true" ]; then
SLACK_TS=$(echo "$SLACK_RESPONSE" | jq -r '.ts')
echo "✅ Successfully sent direct ping to Slack (message ts: $SLACK_TS)"
else
SLACK_ERROR=$(echo "$SLACK_RESPONSE" | jq -r '.error // "Unknown error"')
echo "❌ Failed to send direct ping: $SLACK_ERROR"
echo "Response: $SLACK_RESPONSE"
fi
- name: Setup for notifications
if: needs.parse-comment.outputs.is-direct-ping != 'true'
id: setup-notifications
run: |
PR_NUMBER="${{ steps.generate-comment.outputs.pr-number }}"
CHANGED_FILES_COUNT="${{ needs.analyze-codeowners.outputs.changed-files-count || 0 }}"
# Validate PR_NUMBER
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
echo "ERROR: PR_NUMBER is not set or is null"
exit 1
fi
# Check for large PRs that might cause issues
if [ "$CHANGED_FILES_COUNT" -gt 200 ]; then
echo "⚠️ WARNING: Large PR detected ($CHANGED_FILES_COUNT files). Processing may be slow or fail."
fi
echo "Generating ping message for pending owners..."
# Get members of thirdparty-moreh team to exclude from selection (they're not in Slack)
MOREH_TEAM_MEMBERS=""
MOREH_MEMBERS_API="https://api.github.com/orgs/tenstorrent/teams/thirdparty-moreh/members"
MOREH_MEMBERS_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$MOREH_MEMBERS_API" 2>/dev/null)
MOREH_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$MOREH_MEMBERS_API")
if [ "$MOREH_HTTP_CODE" = "200" ] && [ -n "$MOREH_MEMBERS_DATA" ] && [ "$MOREH_MEMBERS_DATA" != "null" ]; then
MOREH_TEAM_MEMBERS=$(echo "$MOREH_MEMBERS_DATA" | jq -r '.[].login' 2>/dev/null | tr '\n' ',' | sed 's/,$//')
echo "Found thirdparty-moreh team members to exclude: $MOREH_TEAM_MEMBERS"
else
echo "Warning: Could not fetch thirdparty-moreh team members (HTTP $MOREH_HTTP_CODE)"
fi
# Get PR information
PR_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER"
PR_DATA=$(curl -s --max-time 30 -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR_LOGIN=$(echo "$PR_DATA" | jq -r '.user.login')
PR_AUTHOR_NAME=$(echo "$PR_DATA" | jq -r '.user.name // .user.login')
# Fetch Slack users securely (using temp file to avoid ARG_MAX limits)
echo "Fetching all Slack users..."
TEMP_USERS_FILE=$(mktemp)
echo "[]" > "$TEMP_USERS_FILE" # Initialize with empty array
CURSOR=""
PAGE_COUNT=0
while true; do
if [ -n "$CURSOR" ]; then
API_URL="https://slack.com/api/users.list?limit=1000&cursor=$CURSOR"
else
API_URL="https://slack.com/api/users.list?limit=1000"
fi
USER_SEARCH_RESPONSE=$(curl -s -X GET \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_PING_BOT }}" \
-H "Content-Type: application/json" \
"$API_URL")
if [ "$(echo "$USER_SEARCH_RESPONSE" | jq -r '.ok')" = "true" ]; then
USERS_PAGE=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.members')
# Merge arrays using temp files to avoid ARG_MAX limits
echo "$USERS_PAGE" > "${TEMP_USERS_FILE}.page"
jq -s '.[0] + .[1]' "$TEMP_USERS_FILE" "${TEMP_USERS_FILE}.page" > "${TEMP_USERS_FILE}.new"
mv "${TEMP_USERS_FILE}.new" "$TEMP_USERS_FILE"
rm -f "${TEMP_USERS_FILE}.page"
PAGE_COUNT=$((PAGE_COUNT + 1))
CURSOR=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.response_metadata.next_cursor // empty')
if [ -z "$CURSOR" ] || [ "$CURSOR" = "null" ]; then
break
fi
else
ERROR_MSG=$(echo "$USER_SEARCH_RESPONSE" | jq -r '.error // "Unknown error"')
echo "Error: Failed to fetch Slack users: $ERROR_MSG"
break
fi
done
USER_COUNT=$(jq '. | length' "$TEMP_USERS_FILE")
echo "✅ Successfully fetched $USER_COUNT Slack users across $PAGE_COUNT pages"
# Move to RUNNER_TEMP for persistence between steps (avoids environment size limits)
mv "$TEMP_USERS_FILE" "${RUNNER_TEMP}/slack_users.json"
echo "Saved Slack users to ${RUNNER_TEMP}/slack_users.json"
# Output for next steps
{
echo "pr-author-login<<EOF"
echo "$PR_AUTHOR_LOGIN"
echo "EOF"
echo "pr-author-name<<EOF"
echo "$PR_AUTHOR_NAME"
echo "EOF"
echo "moreh-team-members<<EOF"
echo "$MOREH_TEAM_MEMBERS"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Select owners for notification
if: needs.parse-comment.outputs.is-direct-ping != 'true'
id: select-owners
run: |
# Read TEAM_MEMBERS from file (saved in generate-comment step)
TEAM_MEMBERS=$(cat "${RUNNER_TEMP}/team_members.txt")
echo "DEBUG: Loaded team members data from file ($(echo "$TEAM_MEMBERS" | wc -c) bytes)"
# Handle cases where analyze-codeowners job might be skipped (direct ping)
TEAMS="${{ needs.analyze-codeowners.outputs.codeowners-teams || '' }}"
INDIVIDUALS="${{ needs.analyze-codeowners.outputs.codeowners-individuals || '' }}"
APPROVED_REVIEWERS="${{ needs.get-reviews.outputs.approved-reviewers }}"
MOREH_TEAM_MEMBERS="${{ steps.setup-notifications.outputs.moreh-team-members }}"
PR_AUTHOR_LOGIN="${{ steps.setup-notifications.outputs.pr-author-login }}"
# Helper function to check if user is in thirdparty-moreh team
is_moreh_member() {
local username="$1"
if [ -n "$MOREH_TEAM_MEMBERS" ]; then
echo "$MOREH_TEAM_MEMBERS" | grep -q "$username"
else
return 1
fi
}
# Helper function to check if user is the PR author
is_pr_author() {
local username="$1"
[ "$username" = "$PR_AUTHOR_LOGIN" ]
}
# GitHub team to Slack group mapping
get_slack_group_id() {
local github_team="$1"
case "$github_team" in
"@tenstorrent/metalium-developers-infra")
echo "S0985AN7TC5"
;;
"@tenstorrent/metalium-developers-ttnn-core" | "@tenstorrent/metalium-ttnn-core-team")
echo "S0988UJEW8K"
;;
"@tenstorrent/metalium-developers-convolutions")
echo "S09DNR6NAG4"
;;
"@tenstorrent/metalium-developers-ops-data-movement")
echo "S09QQRK1CF8"
;;
"@tenstorrent/metalium-developers-eltwise")
echo "S0ABKSS1D3R"
;;
*)
echo "" # No Slack group available
;;
esac
}
# Get Slack group handle (for debugging/fallback)
get_slack_group_handle() {
local github_team="$1"
case "$github_team" in
"@tenstorrent/metalium-developers-infra")
echo "metalium-developers-infra"
;;
"@tenstorrent/metalium-developers-ttnn-core" | "@tenstorrent/metalium-ttnn-core-team")
echo "metalium-developers-ttnn-core"
;;
"@tenstorrent/metalium-developers-convolutions")
echo "metalium-developers-convolutions"
;;
"@tenstorrent/metalium-developers-ops-data-movement")
echo "metalium-developers-ops-data-movement"
;;
*)
echo ""
;;
esac
}
# STEP 1: Build file -> combined members mapping
# This allows us to check combined approval (same logic as display)
echo "DEBUG select-owners: Step 1 - Building file to members mapping"
declare -A FILES_TO_MEMBERS # sorted_files -> comma-separated usernames
declare -A FILES_TO_TEAMS # sorted_files -> comma-separated team names (for Slack groups)
# Collect team members by files
if [ -n "$TEAMS" ]; then
IFS='§' read -ra TEAM_ARRAY <<< "$TEAMS"
for team_entry in "${TEAM_ARRAY[@]}"; do
[ -z "$team_entry" ] && continue
team=$(echo "$team_entry" | cut -d':' -f1)
[ -z "$team" ] && continue
if [ "$team" = "@tenstorrent/codeowner-bypass" ]; then
continue
fi
team_files=$(echo "$team_entry" | cut -d':' -f2-)
sorted_files=$(echo "$team_files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
# Track team for this file set
if [ -n "${FILES_TO_TEAMS[$sorted_files]}" ]; then
FILES_TO_TEAMS["$sorted_files"]="${FILES_TO_TEAMS[$sorted_files]}|$team"
else
FILES_TO_TEAMS["$sorted_files"]="$team"
fi
# Get team members
if [ -n "$TEAM_MEMBERS" ]; then
team_members_entry=$(echo "$TEAM_MEMBERS" | tr '|' '\n' | grep "^$team:" | head -1)
if [ -n "$team_members_entry" ]; then
team_owners=$(echo "$team_members_entry" | cut -d':' -f2)
if [ "$team_owners" != "insufficient-permissions" ] && [ "$team_owners" != "team-not-found" ] && [ "$team_owners" != "unauthorized" ] && [ "$team_owners" != "api-error" ] && [ "$team_owners" != "no-members" ]; then
# Add team members to combined list for these files
if [ -n "${FILES_TO_MEMBERS[$sorted_files]}" ]; then
FILES_TO_MEMBERS["$sorted_files"]="${FILES_TO_MEMBERS[$sorted_files]},$team_owners"
else
FILES_TO_MEMBERS["$sorted_files"]="$team_owners"
fi
fi
fi
fi
done
fi
# Collect individual members by files
if [ -n "$INDIVIDUALS" ]; then
IFS='§' read -ra PATTERN_GROUPS <<< "$INDIVIDUALS"
for pattern_group in "${PATTERN_GROUPS[@]}"; do
[ -z "$pattern_group" ] && continue
files=$(echo "$pattern_group" | rev | cut -d':' -f1 | rev)
pattern_and_owners=$(echo "$pattern_group" | rev | cut -d':' -f2- | rev)
owners=$(echo "$pattern_and_owners" | cut -d':' -f2-)
sorted_files=$(echo "$files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
# Extract usernames
usernames_list=""
IFS=',' read -ra OWNERS_ARRAY <<< "$owners"
for owner_pair in "${OWNERS_ARRAY[@]}"; do
username=$(echo "$owner_pair" | cut -d'|' -f1)
usernames_list="$usernames_list$username,"
done
usernames_list=$(echo "$usernames_list" | sed 's/,$//')
# Add individual members to combined list for these files
if [ -n "${FILES_TO_MEMBERS[$sorted_files]}" ]; then
FILES_TO_MEMBERS["$sorted_files"]="${FILES_TO_MEMBERS[$sorted_files]},$usernames_list"
else
FILES_TO_MEMBERS["$sorted_files"]="$usernames_list"
fi
done
fi
# STEP 2: Check combined approval for each file set
echo "DEBUG select-owners: Step 2 - Checking combined approval"
declare -A FILES_HAS_APPROVAL # sorted_files -> true/false
# Helper function to check if two file lists have any overlap
files_have_overlap() {
local files1="$1"
local files2="$2"
if [ -z "$files1" ] || [ -z "$files2" ]; then
return 1
fi
# Convert comma-separated lists to arrays and check for any common file
IFS=',' read -ra FILES1_ARRAY <<< "$files1"
IFS=',' read -ra FILES2_ARRAY <<< "$files2"
for file1 in "${FILES1_ARRAY[@]}"; do
for file2 in "${FILES2_ARRAY[@]}"; do
if [ "$file1" = "$file2" ]; then
return 0 # Found overlap
fi
done
done
return 1 # No overlap
}
for sorted_files in "${!FILES_TO_MEMBERS[@]}"; do
combined_members="${FILES_TO_MEMBERS[$sorted_files]}"
# Check if ANY member from combined set has approved
# Also check members from other file sets that overlap with this one
has_approval=false
if [ -n "$combined_members" ]; then
IFS=',' read -ra COMBINED_ARRAY <<< "$combined_members"
for member in "${COMBINED_ARRAY[@]}"; do
[ -z "$member" ] && continue
if echo "$APPROVED_REVIEWERS" | grep -q "$member"; then
has_approval=true
echo "DEBUG select-owners: Files [$sorted_files] approved by $member"
break
fi
done
fi
# If not approved yet, check overlapping file sets
if [ "$has_approval" != "true" ]; then
for other_files in "${!FILES_TO_MEMBERS[@]}"; do
if [ "$other_files" != "$sorted_files" ] && files_have_overlap "$sorted_files" "$other_files"; then
other_members="${FILES_TO_MEMBERS[$other_files]}"
if [ -n "$other_members" ]; then
IFS=',' read -ra OTHER_ARRAY <<< "$other_members"
for member in "${OTHER_ARRAY[@]}"; do
[ -z "$member" ] && continue
if echo "$APPROVED_REVIEWERS" | grep -q "$member"; then
has_approval=true
echo "DEBUG select-owners: Files [$sorted_files] approved by $member (from overlapping set [$other_files])"
break 2 # Break out of both loops
fi
done
fi
fi
done
fi
FILES_HAS_APPROVAL["$sorted_files"]="$has_approval"
done
# STEP 3: Select owners/groups for notification (only from pending file sets)
echo "DEBUG select-owners: Step 3 - Selecting owners for notification"
TEMP_SELECTED_OWNERS=""
SELECTED_SLACK_GROUPS=""
# Process teams - check for Slack groups first, then individual selection
if [ -n "$TEAMS" ]; then
echo "DEBUG: Processing TEAMS='$TEAMS'"
IFS='§' read -ra TEAM_ARRAY <<< "$TEAMS"
echo "DEBUG: Split into ${#TEAM_ARRAY[@]} team entries"
for team_entry in "${TEAM_ARRAY[@]}"; do
echo "DEBUG: Processing team_entry='$team_entry'"
team=$(echo "$team_entry" | cut -d':' -f1)
echo "DEBUG: Extracted team='$team'"
[ -z "$team" ] && continue
if [ "$team" = "@tenstorrent/codeowner-bypass" ]; then
echo "DEBUG: Skipping codeowner-bypass team"
continue
fi
team_files=$(echo "$team_entry" | cut -d':' -f2-)
sorted_files=$(echo "$team_files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
# Check combined approval for this file set
has_approval="${FILES_HAS_APPROVAL[$sorted_files]}"
if [ "$has_approval" = "true" ]; then
echo "Team $team already approved (combined check), skipping"
continue
fi
# Check if this team has a Slack group
SLACK_GROUP_ID=$(get_slack_group_id "$team")
echo "DEBUG: Slack group ID for $team = '$SLACK_GROUP_ID'"
if [ -n "$SLACK_GROUP_ID" ]; then
echo "Team $team has Slack group: $SLACK_GROUP_ID"
# Special handling for metalium-developers-eltwise - only ping group if none of the 3 key members approved
if [ "$team" = "@tenstorrent/metalium-developers-eltwise" ]; then
ELTWISE_KEY_MEMBERS="dchenTT mateusznowakTT cmaryanTT"
ELTWISE_APPROVED=false
for key_member in $ELTWISE_KEY_MEMBERS; do
if echo "$APPROVED_REVIEWERS" | grep -q "$key_member"; then
echo "✅ Key eltwise member $key_member has approved, skipping group ping"
ELTWISE_APPROVED=true
break
fi
done
if [ "$ELTWISE_APPROVED" = "false" ]; then
echo "⚠️ No key eltwise member approved yet, adding Slack group ping"
SELECTED_SLACK_GROUPS="$SELECTED_SLACK_GROUPS$SLACK_GROUP_ID,"
echo "Added pending Slack group: $team -> $SLACK_GROUP_ID"
fi
else
# Standard behavior for other teams with Slack groups
SELECTED_SLACK_GROUPS="$SELECTED_SLACK_GROUPS$SLACK_GROUP_ID,"
echo "Added pending Slack group: $team -> $SLACK_GROUP_ID"
fi
else
echo "Team $team has no Slack group, using individual selection"
# Select individuals from team
if [ -n "$TEAM_MEMBERS" ]; then
team_members_entry=$(echo "$TEAM_MEMBERS" | tr '|' '\n' | grep "^$team:" | head -1)
if [ -n "$team_members_entry" ]; then
team_owners=$(echo "$team_members_entry" | cut -d':' -f2)
if [ "$team_owners" != "insufficient-permissions" ] && [ "$team_owners" != "team-not-found" ] && [ "$team_owners" != "unauthorized" ] && [ "$team_owners" != "api-error" ] && [ "$team_owners" != "no-members" ]; then
IFS=',' read -ra MEMBERS_ARRAY <<< "$team_owners"
# Collect unapproved members, excluding moreh team members and PR author
TEAM_UNAPPROVED=""
for member in "${MEMBERS_ARRAY[@]}"; do
if ! echo "$APPROVED_REVIEWERS" | grep -q "$member"; then
if is_moreh_member "$member"; then
echo "Excluding $member (thirdparty-moreh team member) from selection"
elif is_pr_author "$member"; then
echo "Excluding $member (PR author) from selection"
else
TEAM_UNAPPROVED="$TEAM_UNAPPROVED$member,"
fi
fi
done
TEAM_UNAPPROVED=$(echo "$TEAM_UNAPPROVED" | sed 's/,$//')
# Special handling for @tenstorrent/metalium-api-owners - always include akerteszTT
# BUT ONLY for files under tt_metal/api/
if [ "$team" = "@tenstorrent/metalium-api-owners" ]; then
# Check if any of the files in this group are under tt_metal/api/
FILES_UNDER_API=false
IFS=',' read -ra FILES_ARRAY <<< "$team_files"
for file in "${FILES_ARRAY[@]}"; do
# Use case statement for more robust pattern matching
# Handles paths with or without leading slashes
case "$file" in
tt_metal/api/*|/tt_metal/api/*|./tt_metal/api/*)
FILES_UNDER_API=true
echo "Found file under tt_metal/api/: $file"
break
;;
esac
done
if [ "$FILES_UNDER_API" = "true" ]; then
echo "Special handling for metalium-api-owners team (tt_metal/api/ files) - ensuring akerteszTT is always included"
# Check if akerteszTT is in unapproved list and not already approved
if echo "$TEAM_UNAPPROVED" | grep -q "akerteszTT"; then
# akerteszTT is available, ensure they're selected
TEMP_SELECTED_OWNERS="$TEMP_SELECTED_OWNERS"akerteszTT","
# Remove akerteszTT from unapproved list for random selection
TEAM_UNAPPROVED=$(echo "$TEAM_UNAPPROVED" | tr ',' '\n' | grep -v "^akerteszTT$" | tr '\n' ',' | sed 's/,$//')
echo "Added akerteszTT as required reviewer for metalium-api-owners (tt_metal/api/ files)"
else
echo "akerteszTT already approved or not in team, not adding to selection"
fi
else
echo "metalium-api-owners team owns non-API files (e.g., programming_examples/), using standard selection"
fi
fi
# Common selection logic: select up to 2 owners from remaining unapproved members
# For metalium-api-owners API files: this is in addition to akerteszTT (if added above)
# For all other cases: this is the primary selection
if [ -n "$TEAM_UNAPPROVED" ]; then
IFS=',' read -ra TEAM_MEMBERS_ARRAY <<< "$TEAM_UNAPPROVED"
TEAM_MEMBER_COUNT=${#TEAM_MEMBERS_ARRAY[@]}
if [ $TEAM_MEMBER_COUNT -eq 1 ]; then
TEMP_SELECTED_OWNERS="$TEMP_SELECTED_OWNERS${TEAM_MEMBERS_ARRAY[0]},"
elif [ $TEAM_MEMBER_COUNT -ge 2 ]; then
RAND1=$((RANDOM % TEAM_MEMBER_COUNT))
RAND2=$((RANDOM % TEAM_MEMBER_COUNT))
while [ $RAND2 -eq $RAND1 ]; do
RAND2=$((RANDOM % TEAM_MEMBER_COUNT))
done
TEMP_SELECTED_OWNERS="$TEMP_SELECTED_OWNERS${TEAM_MEMBERS_ARRAY[$RAND1]},${TEAM_MEMBERS_ARRAY[$RAND2]},"
fi
fi
fi
fi
fi
fi
done
fi
# Process individual patterns - select up to 2 owners from each pending pattern
if [ -n "$INDIVIDUALS" ]; then
IFS='§' read -ra PATTERN_GROUPS <<< "$INDIVIDUALS"
for pattern_group in "${PATTERN_GROUPS[@]}"; do
[ -z "$pattern_group" ] && continue
files=$(echo "$pattern_group" | rev | cut -d':' -f1 | rev)
pattern_and_owners=$(echo "$pattern_group" | rev | cut -d':' -f2- | rev)
pattern=$(echo "$pattern_and_owners" | cut -d':' -f1)
owners=$(echo "$pattern_and_owners" | cut -d':' -f2-)
sorted_files=$(echo "$files" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
# Check combined approval for this file set
has_approval="${FILES_HAS_APPROVAL[$sorted_files]}"
if [ "$has_approval" = "true" ]; then
echo "Pattern $pattern already approved (combined check), skipping"
continue
fi
IFS=',' read -ra OWNERS_ARRAY <<< "$owners"
# Collect unapproved owners, excluding moreh team members and PR author
PATTERN_UNAPPROVED=""
for owner_pair in "${OWNERS_ARRAY[@]}"; do
username=$(echo "$owner_pair" | cut -d'|' -f1)
if ! echo "$APPROVED_REVIEWERS" | grep -q "$username"; then
if is_moreh_member "$username"; then
echo "Excluding $username (thirdparty-moreh team member) from pattern selection"
elif is_pr_author "$username"; then
echo "Excluding $username (PR author) from pattern selection"
else
PATTERN_UNAPPROVED="$PATTERN_UNAPPROVED$username,"
fi
fi
done
PATTERN_UNAPPROVED=$(echo "$PATTERN_UNAPPROVED" | sed 's/,$//')
# Select up to 2 owners from this pending pattern
if [ -n "$PATTERN_UNAPPROVED" ]; then
IFS=',' read -ra PATTERN_OWNERS_ARRAY <<< "$PATTERN_UNAPPROVED"
PATTERN_OWNER_COUNT=${#PATTERN_OWNERS_ARRAY[@]}
if [ $PATTERN_OWNER_COUNT -eq 1 ]; then
TEMP_SELECTED_OWNERS="$TEMP_SELECTED_OWNERS${PATTERN_OWNERS_ARRAY[0]},"
elif [ $PATTERN_OWNER_COUNT -ge 2 ]; then
RAND1=$((RANDOM % PATTERN_OWNER_COUNT))
RAND2=$((RANDOM % PATTERN_OWNER_COUNT))
while [ $RAND2 -eq $RAND1 ]; do
RAND2=$((RANDOM % PATTERN_OWNER_COUNT))
done
TEMP_SELECTED_OWNERS="$TEMP_SELECTED_OWNERS${PATTERN_OWNERS_ARRAY[$RAND1]},${PATTERN_OWNERS_ARRAY[$RAND2]},"
fi
fi
done
fi
# Remove trailing comma and prepare outputs
TEMP_SELECTED_OWNERS=$(echo "$TEMP_SELECTED_OWNERS" | sed 's/,$//')
SELECTED_SLACK_GROUPS=$(echo "$SELECTED_SLACK_GROUPS" | sed 's/,$//')
echo "DEBUG: TEMP_SELECTED_OWNERS='$TEMP_SELECTED_OWNERS'"
echo "DEBUG: SELECTED_SLACK_GROUPS='$SELECTED_SLACK_GROUPS'"
if [ -n "$TEMP_SELECTED_OWNERS" ]; then
# Sort and deduplicate individual owners
SELECTED_OWNERS=$(echo "$TEMP_SELECTED_OWNERS" | tr ',' '\n' | sort | uniq | tr '\n' ',' | sed 's/,$//')
else
SELECTED_OWNERS=""
fi
# Check if no owners are available for notification (only PR author was available)
NO_OWNERS_AVAILABLE=""
if [ -z "$SELECTED_OWNERS" ] && [ -z "$SELECTED_SLACK_GROUPS" ]; then
NO_OWNERS_AVAILABLE="true"
fi
echo "DEBUG: Final SELECTED_OWNERS='$SELECTED_OWNERS'"
echo "DEBUG: Final SELECTED_SLACK_GROUPS='$SELECTED_SLACK_GROUPS'"
echo "DEBUG: NO_OWNERS_AVAILABLE='$NO_OWNERS_AVAILABLE'"
echo "selected-owners=$SELECTED_OWNERS" >> $GITHUB_OUTPUT
echo "selected-slack-groups=$SELECTED_SLACK_GROUPS" >> $GITHUB_OUTPUT
echo "no-owners-available=$NO_OWNERS_AVAILABLE" >> $GITHUB_OUTPUT
- name: Send notifications
if: needs.parse-comment.outputs.is-direct-ping != 'true'
run: |
PING_OWNERS="${{ steps.generate-comment.outputs.ping-owners }}"
SEND_SLACK="${{ steps.generate-comment.outputs.send-slack }}"
SELECTED_OWNERS="${{ steps.select-owners.outputs.selected-owners }}"
PR_AUTHOR_LOGIN="${{ steps.setup-notifications.outputs.pr-author-login }}"
PR_AUTHOR_NAME="${{ steps.setup-notifications.outputs.pr-author-name }}"
AUTHOR_NOTES="${{ steps.generate-comment.outputs.author-notes }}"
PR_NUMBER="${{ steps.generate-comment.outputs.pr-number }}"
# Fetch PR title safely to avoid shell injection
PR_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER"
PR_DATA=$(curl -s --max-time 30 -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
# Use #tt-metal-pr-review-requests Slack channel
SLACK_CHANNEL="C07G47JMQHM"
# Define API endpoint
COMMENTS_API="https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/comments"
# Check if we have any owners to ping
if [ -n "$SELECTED_OWNERS" ]; then
# Get full names for selected owners and build ping message
# NOTE: Do NOT use GitHub mention syntax [@username] as it subscribes users to the PR
PING_MESSAGE="Hi"
IFS=',' read -ra SELECTED_ARRAY <<< "$SELECTED_OWNERS"
OWNER_COUNT=${#SELECTED_ARRAY[@]}
for i in "${!SELECTED_ARRAY[@]}"; do
owner="${SELECTED_ARRAY[$i]}"
if [ -n "$owner" ]; then
# Get user info
USER_API="https://api.github.com/users/$owner"
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$USER_API" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$USER_NAME" ] && [ "$USER_NAME" != "null" ]; then
PING_MESSAGE="$PING_MESSAGE $USER_NAME (@$owner)"
else
PING_MESSAGE="$PING_MESSAGE (@$owner)"
fi
# Add comma if not the last owner
if [ $((i + 1)) -lt $OWNER_COUNT ]; then
PING_MESSAGE="$PING_MESSAGE,"
fi
fi
done
# Get full name for PR author
PR_AUTHOR_FULL_NAME="$PR_AUTHOR_NAME" # Default to what we got from PR API
if [ "$PR_AUTHOR_FULL_NAME" = "$PR_AUTHOR_LOGIN" ] || [ -z "$PR_AUTHOR_FULL_NAME" ] || [ "$PR_AUTHOR_FULL_NAME" = "null" ]; then
# Try to fetch full name from GitHub API
AUTHOR_USER_API="https://api.github.com/users/$PR_AUTHOR_LOGIN"
AUTHOR_USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$AUTHOR_USER_API" 2>/dev/null)
AUTHOR_FULL_NAME=$(echo "$AUTHOR_USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$AUTHOR_FULL_NAME" ] && [ "$AUTHOR_FULL_NAME" != "null" ]; then
PR_AUTHOR_FULL_NAME="$AUTHOR_FULL_NAME"
fi
fi
PING_MESSAGE="$PING_MESSAGE, this PR **[""$PR_TITLE""](${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER)** by $PR_AUTHOR_FULL_NAME (@$PR_AUTHOR_LOGIN) needs your approval/review to merge this."
if [ "$PING_OWNERS" = "true" ]; then
# Create new ping comment
echo "Creating ping comment..."
# Create temporary file to avoid shell interpretation of JSON
TEMP_JSON_FILE=$(mktemp)
if jq -n --arg body "$PING_MESSAGE" '{"body": $body}' > "$TEMP_JSON_FILE"; then
echo "JSON file created successfully"
else
echo "Failed to create JSON file"
exit 1
fi
RESPONSE=$(curl -s -w "HTTPSTATUS:%{http_code};" -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
--data-binary @"$TEMP_JSON_FILE" \
"$COMMENTS_API")
# Clean up temp file
rm -f "$TEMP_JSON_FILE"
# Extract HTTP status code
HTTP_STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://' | sed 's/;//')
RESPONSE_BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS:.*//')
if [ "$HTTP_STATUS" = "201" ]; then
echo "✅ Ping comment created successfully for PR #$PR_NUMBER"
echo "📋 Pinged users: $SELECTED_OWNERS"
else
echo "❌ ERROR: Failed to create ping comment (HTTP $HTTP_STATUS)"
echo "Response body: $RESPONSE_BODY"
echo "Full response: $RESPONSE"
exit 1
fi
else
# Just output the ping message to workflow logs (truncated for large messages)
echo "Ping message (not sent):"
if [ ${#PING_MESSAGE} -gt 2000 ]; then
echo "${PING_MESSAGE:0:2000}... (truncated)"
else
echo "$PING_MESSAGE"
fi
fi
else
echo "No pending owners to ping"
fi
- name: Send Slack notifications
if: needs.parse-comment.outputs.is-direct-ping != 'true'
run: |
SEND_SLACK="${{ steps.generate-comment.outputs.send-slack }}"
SELECTED_OWNERS="${{ steps.select-owners.outputs.selected-owners }}"
SELECTED_SLACK_GROUPS="${{ steps.select-owners.outputs.selected-slack-groups }}"
NO_OWNERS_AVAILABLE="${{ steps.select-owners.outputs.no-owners-available }}"
PR_AUTHOR_LOGIN="${{ steps.setup-notifications.outputs.pr-author-login }}"
PR_AUTHOR_NAME="${{ steps.setup-notifications.outputs.pr-author-name }}"
AUTHOR_NOTES="${{ steps.generate-comment.outputs.author-notes }}"
PR_NUMBER="${{ steps.generate-comment.outputs.pr-number }}"
# ... existing code ...
TOTAL_ADDITIONS="${{ needs.analyze-codeowners.outputs.total-additions || 0 }}"
TOTAL_DELETIONS="${{ needs.analyze-codeowners.outputs.total-deletions || 0 }}"
CHANGED_FILES_COUNT="${{ needs.analyze-codeowners.outputs.changed-files-count || 0 }}"
REQUESTER_LOGIN="${{ needs.parse-comment.outputs.comment-author || '' }}"
REQUESTER_NAME="$REQUESTER_LOGIN"
# Load Slack users from file (set in Setup for notifications step)
ALL_SLACK_USERS=$(cat "${RUNNER_TEMP}/slack_users.json")
USER_COUNT=$(echo "$ALL_SLACK_USERS" | jq '. | length')
echo "DEBUG Slack: SEND_SLACK='$SEND_SLACK'"
echo "DEBUG Slack: SELECTED_OWNERS='$SELECTED_OWNERS'"
echo "DEBUG Slack: SELECTED_SLACK_GROUPS='$SELECTED_SLACK_GROUPS'"
echo "DEBUG Slack: NO_OWNERS_AVAILABLE='$NO_OWNERS_AVAILABLE'"
echo "DEBUG Slack: Using cached Slack users ($USER_COUNT users)"
# Fetch PR title safely to avoid shell injection
PR_API="https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER"
PR_DATA=$(curl -s --max-time 30 -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$PR_API")
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
# Use #tt-metal-pr-review-requests Slack channel
SLACK_CHANNEL="C07G47JMQHM"
# GitHub team to Slack group mapping (needed for author notes processing)
get_slack_group_id() {
local github_team="$1"
case "$github_team" in
"@tenstorrent/metalium-developers-infra")
echo "S0985AN7TC5"
;;
"@tenstorrent/metalium-developers-ttnn-core" | "@tenstorrent/metalium-ttnn-core-team")
echo "S0988UJEW8K"
;;
"@tenstorrent/metalium-developers-convolutions")
echo "S09DNR6NAG4"
;;
"@tenstorrent/metalium-developers-ops-data-movement")
echo "S09QQRK1CF8"
;;
"@tenstorrent/metalium-developers-eltwise")
echo "S0ABKSS1D3R"
;;
*)
echo "" # No Slack group available
;;
esac
}
# Send Slack notification if enabled and owners are available
if [ "$SEND_SLACK" = "true" ] && ([ -n "$SELECTED_OWNERS" ] || [ -n "$SELECTED_SLACK_GROUPS" ]) && [ "$NO_OWNERS_AVAILABLE" != "true" ]; then
echo "Sending Slack notification to channel: $SLACK_CHANNEL"
# Function to find Slack user ID by full name (uses pre-fetched user list)
find_slack_user_id() {
local full_name="$1"
local github_username="$2"
local ALL_USERS="$3" # Pre-fetched user list passed as parameter
# Hardcoded mappings for users where fuzzy matching fails
case "$github_username" in
"mradosavljevicTT")
echo "U0837MYG788"
return 0
;;
"nsextonTT")
echo "U08TVGQGGAE"
return 0
;;
"ncvetkovicTT")
echo "U07AUABTEP6"
return 0
;;
"jvegaTT")
echo "U07M7QZ0BQA"
return 0
;;
esac
# Try exact matching first
USER_ID=$(echo "$ALL_USERS" | jq -r --arg name "$full_name" '.[] | select(.real_name == $name or .profile.real_name == $name) | .id' | head -n1)
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
USER_ID=$(echo "$ALL_USERS" | jq -r --arg name "$full_name" '.[] | select(.profile.display_name == $name) | .id' | head -n1)
fi
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
USER_ID=$(echo "$ALL_USERS" | jq -r --arg username "$github_username" '.[] | select(.name == $username or .profile.display_name == $username) | .id' | head -n1)
fi
# Smart word-by-word fuzzy matching if still not found
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
echo "Trying word-by-word fuzzy matching for: $full_name" >&2
IFS=' ' read -ra NAME_WORDS <<< "$full_name"
# Try each word individually - use the first word that has exactly one match
for word in "${NAME_WORDS[@]}"; do
if [ ${#word} -ge 3 ]; then
echo " Checking word: '$word'" >&2
# Skip very common words that might cause false matches
if [ "$word" = "Tenstorrent" ] || [ "$word" = "Inc" ] || [ "$word" = "LLC" ] || [ "$word" = "ULC" ] || [ "$word" = "Corp" ] || [ "$word" = "Ltd" ]; then
echo " '$word' is too common, skipping" >&2
continue
fi
# Find all users that match this word
WORD_MATCHES=$(echo "$ALL_USERS" | jq -r --arg word "$word" '.[] | select((.real_name // "" | ascii_downcase | contains($word | ascii_downcase)) or (.profile.real_name // "" | ascii_downcase | contains($word | ascii_downcase)) or (.profile.display_name // "" | ascii_downcase | contains($word | ascii_downcase))) | .id + "|" + (.real_name // .profile.real_name // .name)')
if [ -n "$WORD_MATCHES" ]; then
# Count unique matches for this word
MATCH_COUNT=$(echo "$WORD_MATCHES" | wc -l)
if [ "$MATCH_COUNT" -eq 1 ]; then
# Exactly one match - use it!
USER_ID=$(echo "$WORD_MATCHES" | cut -d'|' -f1)
MATCHED_NAME=$(echo "$WORD_MATCHES" | cut -d'|' -f2)
echo "✅ Found unique match for '$word': $full_name -> $MATCHED_NAME ($USER_ID)" >&2
break # Stop here, we found our match
else
echo " '$word' matches $MATCH_COUNT users, skipping" >&2
fi
else
echo " '$word' matches 0 users, skipping" >&2
fi
fi
done
# If no unique word match found, report it
if [ -z "$USER_ID" ] || [ "$USER_ID" = "null" ]; then
echo "⚠️ No unique word matches found for: $full_name" >&2
fi
fi
if [ -n "$USER_ID" ] && [ "$USER_ID" != "null" ]; then
echo "$USER_ID"
else
echo ""
fi
}
# Build Slack message with user mentions and group mentions
SLACK_MESSAGE="🔔 *CodeOwners Review Request*"$'\n\n'"Hi"
# Add Slack group mentions first
if [ -n "$SELECTED_SLACK_GROUPS" ]; then
IFS=',' read -ra SLACK_GROUPS_ARRAY <<< "$SELECTED_SLACK_GROUPS"
for group_id in "${SLACK_GROUPS_ARRAY[@]}"; do
if [ -n "$group_id" ]; then
SLACK_MESSAGE="$SLACK_MESSAGE <!subteam^$group_id>"
echo "✅ Added Slack group mention: <!subteam^$group_id>"
fi
done
fi
# Add individual user mentions
SLACK_USER_MENTIONS=""
if [ -n "$SELECTED_OWNERS" ]; then
IFS=',' read -ra SELECTED_ARRAY <<< "$SELECTED_OWNERS"
for owner in "${SELECTED_ARRAY[@]}"; do
if [ -n "$owner" ]; then
# Get full name for this owner
USER_API="https://api.github.com/users/$owner"
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$USER_API" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "null" ]; then
USER_NAME="$owner"
fi
# Find Slack user ID
SLACK_USER_ID=$(find_slack_user_id "$USER_NAME" "$owner" "$ALL_SLACK_USERS")
if [ -n "$SLACK_USER_ID" ]; then
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS <@$SLACK_USER_ID>"
echo "✅ Found Slack user: $USER_NAME -> $SLACK_USER_ID"
else
SLACK_USER_MENTIONS="$SLACK_USER_MENTIONS $USER_NAME"
echo "⚠️ Slack user not found for: $USER_NAME (@$owner), using full name fallback"
fi
fi
done
fi
SLACK_MESSAGE="$SLACK_MESSAGE$SLACK_USER_MENTIONS!"
# Get PR author Slack ID
PR_AUTHOR_FULL_NAME="$PR_AUTHOR_NAME"
if [ "$PR_AUTHOR_FULL_NAME" = "$PR_AUTHOR_LOGIN" ] || [ -z "$PR_AUTHOR_FULL_NAME" ] || [ "$PR_AUTHOR_FULL_NAME" = "null" ]; then
AUTHOR_USER_API="https://api.github.com/users/$PR_AUTHOR_LOGIN"
AUTHOR_USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$AUTHOR_USER_API" 2>/dev/null)
AUTHOR_FULL_NAME=$(echo "$AUTHOR_USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$AUTHOR_FULL_NAME" ] && [ "$AUTHOR_FULL_NAME" != "null" ]; then
PR_AUTHOR_FULL_NAME="$AUTHOR_FULL_NAME"
fi
fi
PR_AUTHOR_SLACK_ID=$(find_slack_user_id "$PR_AUTHOR_FULL_NAME" "$PR_AUTHOR_LOGIN" "$ALL_SLACK_USERS")
if [ -n "$PR_AUTHOR_SLACK_ID" ]; then
PR_AUTHOR_MENTION="<@$PR_AUTHOR_SLACK_ID>"
else
PR_AUTHOR_MENTION="$PR_AUTHOR_FULL_NAME (@$PR_AUTHOR_LOGIN)"
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"📋 *PR:* <${{ github.server_url }}/${{ github.repository }}/pull/$PR_NUMBER|$PR_TITLE>"
# Add author and requested by information (combine on same line if different)
if [ -n "$REQUESTER_LOGIN" ] && [ "$REQUESTER_LOGIN" != "$PR_AUTHOR_LOGIN" ]; then
# Get full name for requester if available
REQUESTER_USER_API="https://api.github.com/users/$REQUESTER_LOGIN"
REQUESTER_USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$REQUESTER_USER_API" 2>/dev/null)
REQUESTER_FULL_NAME=$(echo "$REQUESTER_USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -n "$REQUESTER_FULL_NAME" ] && [ "$REQUESTER_FULL_NAME" != "null" ]; then
REQUESTER_NAME="$REQUESTER_FULL_NAME"
fi
# Find Slack user ID for requester and create mention
REQUESTER_SLACK_ID=$(find_slack_user_id "$REQUESTER_NAME" "$REQUESTER_LOGIN" "$ALL_SLACK_USERS")
if [ -n "$REQUESTER_SLACK_ID" ]; then
REQUESTER_MENTION="<@$REQUESTER_SLACK_ID>"
echo "✅ Found Slack user for requester: $REQUESTER_NAME -> $REQUESTER_SLACK_ID"
else
REQUESTER_MENTION="$REQUESTER_NAME (@$REQUESTER_LOGIN)"
echo "⚠️ Slack user not found for requester: $REQUESTER_NAME (@$REQUESTER_LOGIN), using fallback"
fi
# Combine Author and Requested by on same line with separator
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"👤 *Author:* $PR_AUTHOR_MENTION │ 🔄 *Requested by:* $REQUESTER_MENTION"
else
# Just author
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"👤 *Author:* $PR_AUTHOR_MENTION"
fi
# Add changes count if available
if ([ -n "$TOTAL_ADDITIONS" ] && [ "$TOTAL_ADDITIONS" -gt 0 ]) || ([ -n "$TOTAL_DELETIONS" ] && [ "$TOTAL_DELETIONS" -gt 0 ]); then
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"📊 *Changes:* ($CHANGED_FILES_COUNT files) +$TOTAL_ADDITIONS -$TOTAL_DELETIONS"
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n'"🔍 *Action Required:* This PR needs your code review/approval to proceed with merging."
# Add author notes if provided
if [ -n "$AUTHOR_NOTES" ]; then
echo "Processing author notes for GitHub username mentions..."
# Process author notes to convert @username to full names or Slack mentions
PROCESSED_NOTES="$AUTHOR_NOTES"
# First, convert markdown links [text](url) to Slack format <url|text>
# This regex matches [text](url) and converts to Slack format
PROCESSED_NOTES=$(python3 -c "import sys; text=sys.stdin.read(); import re; print(re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<\2|\1>', text))" <<< "$PROCESSED_NOTES")
echo "Converted markdown links to Slack format"
# Also detect plain URLs (http:// or https://) and convert them to clickable Slack links
# Use negative lookbehind to avoid matching URLs already in Slack format (<url|text> or <url>)
PROCESSED_NOTES=$(python3 -c "import sys, re; text=sys.stdin.read(); print(re.sub(r'(?<![<])(\bhttps?://[^\s<>]+)(?![|>])', r'<\1>', text))" <<< "$PROCESSED_NOTES")
echo "Converted plain URLs to Slack links"
# Find all GitHub usernames in author notes (pattern: @username, but NOT @org/team)
# This regex matches @username but excludes @org/team-name patterns
GITHUB_USERNAMES=$(echo "$AUTHOR_NOTES" | grep -oE '@[a-zA-Z0-9_-]+' | grep -v '@.*/' | sort | uniq)
if [ -n "$GITHUB_USERNAMES" ]; then
echo "Found GitHub usernames in author notes: $GITHUB_USERNAMES"
for github_mention in $GITHUB_USERNAMES; do
if [ -n "$github_mention" ]; then
# Remove @ prefix to get clean username
clean_username=$(echo "$github_mention" | sed 's/^@//')
# Skip if this looks like an organization name (common org names)
if [ "$clean_username" = "tenstorrent" ] || [ "$clean_username" = "github" ] || [ "$clean_username" = "microsoft" ]; then
echo "Skipping organization name: $github_mention"
continue
fi
# Get full name from GitHub API
USER_API="https://api.github.com/users/$clean_username"
USER_DATA=$(curl -s -H "Authorization: Bearer ${{ secrets.CODEOWNERS_GROUP_ANALYSIS_PAT }}" \
-H "Accept: application/vnd.github.v3+json" \
"$USER_API" 2>/dev/null)
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // empty' 2>/dev/null)
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "null" ]; then
USER_NAME="$clean_username"
fi
# Try to find Slack user ID
SLACK_USER_ID=$(find_slack_user_id "$USER_NAME" "$clean_username" "$ALL_SLACK_USERS")
if [ -n "$SLACK_USER_ID" ]; then
# Use Slack mention for direct ping
REPLACEMENT="<@$SLACK_USER_ID>"
echo "✅ Converting $github_mention to Slack mention: $USER_NAME -> $SLACK_USER_ID"
else
# Use full name as fallback
REPLACEMENT="@$USER_NAME"
echo "⚠️ Converting $github_mention to full name: $USER_NAME"
fi
# Replace in notes (escape special characters for sed)
ESCAPED_MENTION=$(echo "$github_mention" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_REPLACEMENT=$(echo "$REPLACEMENT" | sed 's/[[\.*^$()+?{|\\]/\\&/g')
PROCESSED_NOTES=$(echo "$PROCESSED_NOTES" | sed "s/$ESCAPED_MENTION/$ESCAPED_REPLACEMENT/g")
fi
done
echo "Final processed author notes: $PROCESSED_NOTES"
AUTHOR_NOTES="$PROCESSED_NOTES"
fi
# Find GitHub team mentions and optionally convert to Slack groups
GITHUB_TEAMS=$(echo "$PROCESSED_NOTES" | grep -oE '@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+' | sort | uniq)
if [ -n "$GITHUB_TEAMS" ]; then
echo "Found GitHub team mentions: $GITHUB_TEAMS"
for team_mention in $GITHUB_TEAMS; do
# Check if this team has a corresponding Slack group
SLACK_GROUP_ID=$(get_slack_group_id "$team_mention")
if [ -n "$SLACK_GROUP_ID" ]; then
# Replace with Slack group mention
TEAM_REPLACEMENT="<!subteam^$SLACK_GROUP_ID>"
echo "✅ Converting team $team_mention to Slack group: $SLACK_GROUP_ID"
ESCAPED_TEAM=$(echo "$team_mention" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_TEAM_REPLACEMENT=$(echo "$TEAM_REPLACEMENT" | sed 's/[[\.*^$()+?{|\\]/\\&/g')
PROCESSED_NOTES=$(echo "$PROCESSED_NOTES" | sed "s/$ESCAPED_TEAM/$ESCAPED_TEAM_REPLACEMENT/g")
else
echo "⚠️ No Slack group found for team: $team_mention"
fi
done
fi
# Update AUTHOR_NOTES with final processed version for use in Slack message
AUTHOR_NOTES="$PROCESSED_NOTES"
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"💬 *Author Notes:* $AUTHOR_NOTES"
fi
SLACK_MESSAGE="$SLACK_MESSAGE"$'\n\n'"Please review when you have a moment. Thank you! 🚀"
# Debug: Show the final message being sent (truncated for large messages)
echo "🔍 Final Slack message:"
echo "================================"
if [ ${#SLACK_MESSAGE} -gt 2000 ]; then
echo "${SLACK_MESSAGE:0:2000}... (truncated)"
else
echo "$SLACK_MESSAGE"
fi
echo "================================"
# Send message to Slack
# Use a temp file to avoid jq escaping issues
echo "$SLACK_MESSAGE" > /tmp/slack_message.txt
SLACK_PAYLOAD=$(jq -n \
--arg channel "$SLACK_CHANNEL" \
--rawfile text /tmp/slack_message.txt \
'{
channel: $channel,
text: $text
}')
echo "🔍 Final JSON payload:"
echo "$SLACK_PAYLOAD"
echo "Attempting to send message to Slack..."
SLACK_RESPONSE=$(curl -s --max-time 30 -X POST \
-H "Authorization: Bearer ${{ secrets.CODEOWNERS_PING_BOT }}" \
-H "Content-type: application/json" \
-d "$SLACK_PAYLOAD" \
"https://slack.com/api/chat.postMessage")
if [ "$(echo "$SLACK_RESPONSE" | jq -r '.ok')" = "true" ]; then
SLACK_TS=$(echo "$SLACK_RESPONSE" | jq -r '.ts')
echo "✅ Successfully sent Slack notification to channel $SLACK_CHANNEL (message ts: $SLACK_TS)"
echo "📋 Notified users: $SELECTED_OWNERS"
echo "📋 Notified groups: $SELECTED_SLACK_GROUPS"
else
SLACK_ERROR=$(echo "$SLACK_RESPONSE" | jq -r '.error // "Unknown error"')
echo "❌ Failed to send Slack notification: $SLACK_ERROR"
echo "Response: $SLACK_RESPONSE"
fi
# Clean up temp file
rm -f /tmp/slack_message.txt
elif [ "$SEND_SLACK" = "true" ] && [ "$NO_OWNERS_AVAILABLE" = "true" ]; then
echo "Slack notification enabled but no owners available for notification (only PR author is codeowner)"
elif [ "$SEND_SLACK" = "true" ] && [ -z "$SELECTED_OWNERS" ] && [ -z "$SELECTED_SLACK_GROUPS" ]; then
echo "Slack notification enabled but no pending owners or groups to notify"
else
echo "Slack notification disabled"
fi