1+ // =============================================================================
2+ // Add Reviewers as Assignees Bot
3+ // =============================================================================
4+ //
5+ // This script automatically adds requested reviewers (individuals + expanded
6+ // team members) as assignees on a PR. Makes it easier for reviewers to find
7+ // their PRs via the "Assigned to me" filter.
8+ //
9+ // Triggered by:
10+ // - pull_request review_requested event
11+ // - workflow_dispatch (for manual testing)
12+ //
13+ // Key behavior:
14+ // - Only adds users who aren't already assignees
15+ // - Expands teams with graceful fallback on permission/visibility issues
16+ // - Supports dry-run mode
17+ // - Batches assignee updates (GitHub limit = 10 per call)
18+ // - Comprehensive logging for debugging
19+ //
20+ // Called by: .github/workflows/bot-pr-add-reviewers-as-assignees.yml
21+
22+ /**
23+ * Checks if the script is running in dry-run mode.
24+ *
25+ * Returns true if DRY_RUN env var is set to "true" (case-insensitive).
26+ */
27+ function isDryRun ( ) {
28+ return / ^ t r u e $ / i. test ( process . env . DRY_RUN || '' ) ;
29+ }
30+
31+ /**
32+ * Main handler that adds requested reviewers (individuals + expanded team members)
33+ * as assignees on a Pull Request.
34+ *
35+ * @param {Object } params
36+ * @param {Object } params.github - GitHub Octokit client from actions/github-script
37+ * @param {Object } params.context - GitHub Actions context
38+ * @returns {Promise<void> }
39+ */
40+ module . exports = async ( { github, context } ) => {
41+ const dryRun = isDryRun ( ) ;
42+ if ( dryRun ) {
43+ console . log ( '[reviewers-assignee] Running in DRY-RUN mode - no changes will be made' ) ;
44+ }
45+
46+ try {
47+ let prNumber = context . payload . pull_request ?. number ;
48+ let owner = context . repo . owner ;
49+ let repo = context . repo . repo ;
50+
51+ // Support workflow_dispatch with manual PR number
52+ if ( context . eventName === 'workflow_dispatch' && context . payload . inputs ?. pr_number !== undefined ) {
53+ const manualPrNumber = Number ( context . payload . inputs . pr_number ) ;
54+ if ( ! Number . isInteger ( manualPrNumber ) || manualPrNumber <= 0 ) {
55+ console . log ( '[reviewers-assignee] Invalid PR number supplied. Skipping.' ) ;
56+ return ;
57+ }
58+ prNumber = manualPrNumber ;
59+ }
60+ if ( ! Number . isInteger ( prNumber ) || prNumber <= 0 ) {
61+ console . log ( '[reviewers-assignee] No PR number found. Skipping.' ) ;
62+ return ;
63+ }
64+
65+ console . log ( `[reviewers-assignee] Processing PR #${ prNumber } in ${ owner } /${ repo } ` ) ;
66+
67+ // Fetch fresh PR data
68+ const { data : pr } = await github . rest . pulls . get ( {
69+ owner,
70+ repo,
71+ pull_number : prNumber
72+ } ) ;
73+
74+ const requestedReviewers = pr . requested_reviewers || [ ] ;
75+ const requestedTeams = pr . requested_teams || [ ] ;
76+ const currentAssignees = new Set ( pr . assignees ?. map ( a => a . login ) || [ ] ) ;
77+
78+ console . log ( `[reviewers-assignee] Requested reviewers: ${ requestedReviewers . length } , teams: ${ requestedTeams . length } ` ) ;
79+
80+ const usersToAssign = new Set ( ) ;
81+
82+ // Add individual reviewers
83+ for ( const reviewer of requestedReviewers ) {
84+ if ( reviewer ?. login && ! currentAssignees . has ( reviewer . login ) ) {
85+ usersToAssign . add ( reviewer . login ) ;
86+ }
87+ }
88+
89+ // Expand teams with pagination
90+ for ( const team of requestedTeams ) {
91+ if ( ! team ?. slug ) continue ;
92+ try {
93+ const members = await github . paginate ( github . rest . teams . listMembersInOrg , {
94+ org : owner ,
95+ team_slug : team . slug ,
96+ per_page : 100
97+ } ) ;
98+
99+ for ( const member of members ) {
100+ if ( member ?. login && ! currentAssignees . has ( member . login ) ) {
101+ usersToAssign . add ( member . login ) ;
102+ }
103+ }
104+ console . log ( `[reviewers-assignee] Expanded team ${ team . slug } → ${ members . length } members` ) ;
105+ } catch ( error ) {
106+ if ( error ?. status === 403 || error ?. status === 404 ) {
107+ console . log ( `[reviewers-assignee] Could not expand team ${ team . slug } due to permissions/visibility: ${ error . message } ` ) ;
108+ continue ;
109+ }
110+ console . error ( `[reviewers-assignee] Failed to expand team ${ team . slug } : ${ error . message } ` ) ;
111+ throw error ;
112+ }
113+ }
114+
115+ if ( usersToAssign . size === 0 ) {
116+ console . log ( '[reviewers-assignee] No new users to assign. Done.' ) ;
117+ return ;
118+ }
119+
120+ const assigneesList = Array . from ( usersToAssign ) ;
121+ console . log ( `[reviewers-assignee] Will assign: ${ assigneesList . join ( ', ' ) } ` ) ;
122+
123+ if ( dryRun ) {
124+ console . log ( '[reviewers-assignee] DRY-RUN: Would have added the above users as assignees' ) ;
125+ return ;
126+ }
127+
128+ // Add up to 10 assignees at a time
129+ const ASSIGNEE_BATCH_SIZE = 10 ;
130+ let addedCount = 0 ;
131+ let permissionDenied = false ;
132+ for ( let i = 0 ; i < assigneesList . length ; i += ASSIGNEE_BATCH_SIZE ) {
133+ const batch = assigneesList . slice ( i , i + ASSIGNEE_BATCH_SIZE ) ;
134+ try {
135+ await github . rest . issues . addAssignees ( {
136+ owner,
137+ repo,
138+ issue_number : prNumber ,
139+ assignees : batch
140+ } ) ;
141+ addedCount += batch . length ;
142+ console . log ( `[reviewers-assignee] Added batch of ${ batch . length } assignees` ) ;
143+ } catch ( error ) {
144+ if ( error ?. status === 403 ) {
145+ permissionDenied = true ;
146+ console . log ( '[reviewers-assignee] Skipping remaining assignment due to insufficient permissions' ) ;
147+ break ;
148+ }
149+ console . error ( `[reviewers-assignee] Failed to add assignees batch: ${ error . message } ` ) ;
150+ throw error ; // Re-throw other errors
151+ }
152+ }
153+
154+ console . log (
155+ permissionDenied
156+ ? `[reviewers-assignee] Added ${ addedCount } reviewer(s) before permissions blocked further assignment on PR #${ prNumber } `
157+ : `[reviewers-assignee] Successfully added ${ addedCount } reviewer(s) as assignee(s) on PR #${ prNumber } `
158+ ) ;
159+
160+ } catch ( error ) {
161+ console . error ( '[reviewers-assignee] Error:' , error . message ) ;
162+ if ( error . status ) console . error ( 'Status:' , error . status ) ;
163+ throw error ;
164+ }
165+ } ;
0 commit comments