Fix SDPA TT_METAL_WATCHER issues #18336
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>📁 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>📁 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 |