1+ #!/usr/bin/env node
2+ /**
3+ * GitHub Milestone Assignment Script
4+ * Assigns "next release" milestone to closed issues when merged to main
5+ *
6+ * Usage:
7+ *
8+ * 1. Setup Environment variables to run locally:
9+ * export GITHUB_TOKEN="ghp_your_token_here"
10+ * export GITHUB_REPOSITORY="MobilityData/mobility-feed-api"
11+ *
12+ * 2. Install dependencies
13+ * npm install @octokit/rest
14+ *
15+ * 3. Run in dry mode
16+ * node scripts/assign-next-release-milestone.js --dry-run
17+ * - Optional. If provided then the script will do a dry run without affecting any issues, logging will explain what will be done when running in production mode.
18+ *
19+ * 4. Run in production mode
20+ * node scripts/assign-next-release-milestone.js
21+ */
22+
23+ const { Octokit } = require ( '@octokit/rest' ) ;
24+
25+ // Parse command line arguments
26+ const args = process . argv . slice ( 2 ) ;
27+ const hasArgDryRun = args . includes ( '--dry-run' ) ;
28+
29+ // Determine dry run mode based on priority:
30+ // 1. Command line argument --dry-run takes precedence
31+ // 2. Environment variable DRY_RUN
32+ // 3. Default to false
33+ const isDryRun = hasArgDryRun || process . env . DRY_RUN === 'true' ;
34+
35+ // Validate required environment variables
36+ const token = process . env . GITHUB_TOKEN ;
37+ const repository = process . env . GITHUB_REPOSITORY ;
38+
39+ if ( ! token ) {
40+ console . error ( '❌ GITHUB_TOKEN environment variable is required' ) ;
41+ process . exit ( 1 ) ;
42+ }
43+
44+ if ( ! repository ) {
45+ console . error ( '❌ GITHUB_REPOSITORY environment variable is required (format: owner/repo)' ) ;
46+ process . exit ( 1 ) ;
47+ }
48+
49+ const [ owner , repo ] = repository . split ( '/' ) ;
50+ if ( ! owner || ! repo ) {
51+ console . error ( '❌ GITHUB_REPOSITORY must be in format "owner/repo"' ) ;
52+ process . exit ( 1 ) ;
53+ }
54+
55+ // Initialize Octokit
56+ const octokit = new Octokit ( {
57+ auth : token ,
58+ } ) ;
59+
60+ async function main ( ) {
61+ try {
62+ if ( isDryRun ) {
63+ console . log ( 'RUNNING IN DRY RUN MODE - No changes will be made' ) ;
64+ }
65+
66+ console . log ( `Processing repository: ${ owner } /${ repo } ` ) ;
67+
68+ // Get the "next release" milestone
69+ console . log ( 'Fetching milestones...' ) ;
70+ const milestones = await octokit . rest . issues . listMilestones ( {
71+ owner,
72+ repo,
73+ state : 'open'
74+ } ) ;
75+
76+ const nextReleaseMilestone = milestones . data . find (
77+ milestone => milestone . title . toLowerCase ( ) === 'next release'
78+ ) ;
79+
80+ if ( ! nextReleaseMilestone ) {
81+ console . log ( '❌ "Next Release" milestone not found' ) ;
82+ return ;
83+ }
84+
85+ console . log ( `Found milestone: ${ nextReleaseMilestone . title } (ID: ${ nextReleaseMilestone . number } )` ) ;
86+
87+ // Get all closed issues
88+ console . log ( 'Fetching closed issues...' ) ;
89+ const issues = await octokit . rest . issues . listForRepo ( {
90+ owner,
91+ repo,
92+ state : 'closed' ,
93+ per_page : 100
94+ } ) ;
95+
96+ console . log ( `Found ${ issues . data . length } closed issues` ) ;
97+
98+ let processedCount = 0 ;
99+ let assignedCount = 0 ;
100+
101+ // Process each issue
102+ for ( const issue of issues . data ) {
103+ // Skip pull requests (they appear in issues API but have pull_request property)
104+ if ( issue . pull_request ) {
105+ continue ;
106+ }
107+
108+ processedCount ++ ;
109+
110+ // Skip if already has the next release milestone
111+ if ( issue . milestone && issue . milestone . number === nextReleaseMilestone . number ) {
112+ console . log ( `Issue #${ issue . number } already has Next Release milestone, skipping...` ) ;
113+ continue ;
114+ }
115+
116+ // Check if issue was closed by a merged PR
117+ console . log ( `Checking timeline for issue #${ issue . number } ...` ) ;
118+ const timelineEvents = await octokit . rest . issues . listEventsForTimeline ( {
119+ owner,
120+ repo,
121+ issue_number : issue . number
122+ } ) ;
123+
124+ const wasMergedToMain = timelineEvents . data . some ( event => {
125+ return event . event === 'closed' &&
126+ event . commit_id &&
127+ event . source &&
128+ event . source . issue &&
129+ event . source . issue . pull_request &&
130+ event . source . issue . pull_request . merged_at ;
131+ } ) ;
132+
133+ if ( wasMergedToMain ) {
134+ console . log ( `${ isDryRun ? '[DRY RUN] Would assign' : 'Assigning' } milestone to issue #${ issue . number } : ${ issue . title } ` ) ;
135+
136+ if ( ! isDryRun ) {
137+ try {
138+ await octokit . rest . issues . update ( {
139+ owner,
140+ repo,
141+ issue_number : issue . number ,
142+ milestone : nextReleaseMilestone . number
143+ } ) ;
144+
145+ console . log ( `✅ Successfully assigned milestone to issue #${ issue . number } ` ) ;
146+ assignedCount ++ ;
147+ } catch ( error ) {
148+ console . error ( `❌ Failed to assign milestone to issue #${ issue . number } :` , error . message ) ;
149+ }
150+ } else {
151+ console . log ( `[DRY RUN] Skipped actual assignment for issue #${ issue . number } ` ) ;
152+ assignedCount ++ ;
153+ }
154+ } else {
155+ console . log ( `Issue #${ issue . number } was not merged to main, skipping` ) ;
156+ }
157+ }
158+
159+ console . log ( `\n\nSummary:` ) ;
160+ console . log ( `- Processed ${ processedCount } issues` ) ;
161+ console . log ( `- ${ isDryRun ? 'Would assign' : 'Assigned' } milestone to ${ assignedCount } issues` ) ;
162+ console . log ( `${ isDryRun ? '[DRY RUN] Test complete!' : '✅ Milestone assignment complete!' } ` ) ;
163+
164+ } catch ( error ) {
165+ console . error ( '❌ Script failed:' , error . message ) ;
166+ process . exit ( 1 ) ;
167+ }
168+ }
169+
170+ // Run the script
171+ main ( ) ;
0 commit comments