1
+ import * as core from '@actions/core' ;
2
+ import { context , getOctokit } from '@actions/github' ;
3
+ import { GitHub } from '@actions/github/lib/utils' ;
4
+ import { retrievePullRequestFiles } from './shared/pull-request' ;
5
+ import micromatch from 'micromatch' ;
6
+
7
+ type TeamFiles = Record < string , string [ ] > ;
8
+
9
+ type TeamEmojis = {
10
+ [ team : string ] : string ;
11
+ }
12
+
13
+ type CodeOwnerRule = {
14
+ pattern : string ;
15
+ owners : string [ ] ;
16
+ }
17
+
18
+ // Team emoji mappings
19
+ const teamEmojis : TeamEmojis = {
20
+ '@MetaMask/extension-devs' : '🧩' ,
21
+ '@MetaMask/policy-reviewers' : '📜' ,
22
+ '@MetaMask/supply-chain' : '🔗' ,
23
+ '@MetaMask/snaps-devs' : '🫰' ,
24
+ '@MetaMask/extension-security-team' : '🔒' ,
25
+ '@MetaMask/extension-privacy-reviewers' : '🕵️' ,
26
+ '@MetaMask/confirmations' : '✅' ,
27
+ '@MetaMask/design-system-engineers' : '🎨' ,
28
+ '@MetaMask/notifications' : '🔔' ,
29
+ '@MetaMask/identity' : '🪪' ,
30
+ '@MetaMask/accounts-engineers' : '🔑' ,
31
+ '@MetaMask/swaps-engineers' : '🔄' ,
32
+ '@MetaMask/ramp' : '📈' ,
33
+ '@MetaMask/wallet-ux' : '🖥️' ,
34
+ '@MetaMask/metamask-assets' : '💎' ,
35
+ } ;
36
+
37
+ main ( ) . catch ( ( error : Error ) : void => {
38
+ console . error ( error ) ;
39
+ process . exit ( 1 ) ;
40
+ } ) ;
41
+
42
+ async function main ( ) : Promise < void > {
43
+ const PR_COMMENT_TOKEN = process . env . PR_COMMENT_TOKEN ;
44
+ if ( ! PR_COMMENT_TOKEN ) {
45
+ core . setFailed ( 'PR_COMMENT_TOKEN not found' ) ;
46
+ process . exit ( 1 ) ;
47
+ }
48
+
49
+ // Initialise octokit, required to call Github API
50
+ const octokit : InstanceType < typeof GitHub > = getOctokit ( PR_COMMENT_TOKEN ) ;
51
+
52
+
53
+ const owner = context . repo . owner ;
54
+ const repo = context . repo . repo ;
55
+ const prNumber = context . payload . pull_request ?. number ;
56
+ if ( ! prNumber ) {
57
+ core . setFailed ( 'Pull request number not found' ) ;
58
+ process . exit ( 1 ) ;
59
+ }
60
+
61
+ // Get the changed files in the PR
62
+ const changedFiles = await retrievePullRequestFiles ( octokit , owner , repo , prNumber ) ;
63
+
64
+ // Read and parse the CODEOWNERS file
65
+ const codeownersContent = await getCodeownersContent ( octokit , owner , repo ) ;
66
+ const codeowners = parseCodeowners ( codeownersContent ) ;
67
+
68
+ // Match files to codeowners
69
+ const fileOwners = matchFilesToCodeowners ( changedFiles , codeowners ) ;
70
+
71
+ // Group files by team
72
+ const teamFiles = groupFilesByTeam ( fileOwners ) ;
73
+
74
+ // If no teams need to review, don't create or update comments
75
+ if ( Object . keys ( teamFiles ) . length === 0 ) {
76
+ console . log ( 'No files requiring codeowner review, skipping comment' ) ;
77
+
78
+ // Check for existing bot comment and delete it if it exists
79
+ // (in case previous version of PR had files requiring review)
80
+ await deleteExistingComment ( octokit , owner , repo , prNumber ) ;
81
+ return ;
82
+ }
83
+
84
+ // Create the comment body
85
+ const commentBody = createCommentBody ( teamFiles , teamEmojis ) ;
86
+
87
+ // Check for an existing comment and update or create as needed
88
+ await updateOrCreateComment ( octokit , owner , repo , prNumber , commentBody ) ;
89
+ }
90
+
91
+ async function getCodeownersContent (
92
+ octokit : InstanceType < typeof GitHub > ,
93
+ owner : string ,
94
+ repo : string
95
+ ) : Promise < string > {
96
+ try {
97
+ const response = await octokit . rest . repos . getContent ( {
98
+ owner,
99
+ repo,
100
+ path : '.github/CODEOWNERS' ,
101
+ headers : {
102
+ 'accept' : 'application/vnd.github.raw' ,
103
+ } ,
104
+ } ) ;
105
+
106
+ if ( response ) {
107
+ return response . data as unknown as string ;
108
+ }
109
+
110
+ throw new Error ( 'Failed to get CODEOWNERS file content' ) ;
111
+ } catch ( error ) {
112
+ throw new Error ( `Failed to get CODEOWNERS file: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
113
+ }
114
+ }
115
+
116
+ function parseCodeowners ( content : string ) : CodeOwnerRule [ ] {
117
+ return content
118
+ . split ( '\n' )
119
+ . filter ( line => line . trim ( ) && ! line . startsWith ( '#' ) )
120
+ . map ( line => {
121
+ const [ pattern , ...owners ] = line . trim ( ) . split ( / \s + / ) ;
122
+ return { pattern, owners } ;
123
+ } ) ;
124
+ }
125
+
126
+ function matchFilesToCodeowners ( files : string [ ] , codeowners : CodeOwnerRule [ ] ) : Map < string , Set < string > > {
127
+ const fileOwners : Map < string , Set < string > > = new Map ( ) ;
128
+
129
+ files . forEach ( file => {
130
+ for ( const { pattern, owners } of codeowners ) {
131
+ if ( isFileMatchingPattern ( file , pattern ) ) {
132
+ // Not breaking here to allow for multiple patterns to match the same file
133
+ // i.e. if a directory is owned by one team, but specific files within that directory
134
+ // are also owned by another team, the file will be added to both teams
135
+ const ownerSet = fileOwners . get ( file ) ;
136
+ if ( ! ownerSet ) {
137
+ fileOwners . set ( file , new Set ( owners ) ) ;
138
+ } else {
139
+ owners . forEach ( ( owner ) => ownerSet . add ( owner ) ) ;
140
+ }
141
+ }
142
+ }
143
+ } ) ;
144
+
145
+ return fileOwners ;
146
+ }
147
+
148
+ function isFileMatchingPattern ( file : string , pattern : string ) : boolean {
149
+ // Case 1: Pattern explicitly ends with a slash (e.g., "docs/")
150
+ if ( pattern . endsWith ( '/' ) ) {
151
+ return micromatch . isMatch ( file , `${ pattern } **` ) ;
152
+ }
153
+
154
+ // Case 2: Pattern doesn't end with a file extension - treat as directory
155
+ if ( ! pattern . match ( / \. [ ^ / ] * $ / ) ) {
156
+ // Treat as directory - match this path and everything under it
157
+ return micromatch . isMatch ( file , `${ pattern } /**` ) ;
158
+ }
159
+
160
+ // Case 3: Pattern with file extension or already has wildcards
161
+ return micromatch . isMatch ( file , pattern ) ;
162
+ }
163
+
164
+ function groupFilesByTeam ( fileOwners : Map < string , Set < string > > ) : TeamFiles {
165
+ const teamFiles : TeamFiles = { } ;
166
+
167
+ fileOwners . forEach ( ( owners , file ) => {
168
+ owners . forEach ( owner => {
169
+ if ( ! teamFiles [ owner ] ) {
170
+ teamFiles [ owner ] = [ ] ;
171
+ }
172
+ teamFiles [ owner ] . push ( file ) ;
173
+ } ) ;
174
+ } ) ;
175
+
176
+ // Sort files within each team for consistent ordering
177
+ Object . values ( teamFiles ) . forEach ( files => files . sort ( ) ) ;
178
+
179
+ return teamFiles ;
180
+ }
181
+
182
+ function createCommentBody ( teamFiles : TeamFiles , teamEmojis : TeamEmojis ) : string {
183
+ let commentBody = `<!-- METAMASK-CODEOWNERS-BOT -->\n✨ Files requiring CODEOWNER review ✨\n---\n` ;
184
+
185
+ // Sort teams for consistent ordering
186
+ const allOwners = Object . keys ( teamFiles ) ;
187
+
188
+ const teamOwners = allOwners . filter ( owner => owner . startsWith ( '@MetaMask/' ) ) ;
189
+ const individualOwners = allOwners . filter ( owner => ! owner . startsWith ( '@MetaMask/' ) ) ;
190
+
191
+ const sortFn = ( a , b ) => a . toLowerCase ( ) . localeCompare ( b . toLowerCase ( ) ) ;
192
+ const sortedTeamOwners = teamOwners . sort ( sortFn ) ;
193
+ const sortedIndividualOwners = individualOwners . sort ( sortFn ) ;
194
+
195
+ const sortedOwners = [ ...sortedTeamOwners , ...sortedIndividualOwners ] ;
196
+
197
+ sortedOwners . forEach ( team => {
198
+ const emoji = teamEmojis [ team ] || '👨🔧' ;
199
+ commentBody += `${ emoji } ${ team } \n` ;
200
+ teamFiles [ team ] . forEach ( file => {
201
+ commentBody += `- \`${ file } \`\n` ;
202
+ } ) ;
203
+ commentBody += '\n' ;
204
+ } ) ;
205
+
206
+ return commentBody ;
207
+ }
208
+
209
+ async function deleteExistingComment (
210
+ octokit : InstanceType < typeof GitHub > ,
211
+ owner : string ,
212
+ repo : string ,
213
+ prNumber : number
214
+ ) : Promise < void > {
215
+ // Get existing comments
216
+ const { data : comments } = await octokit . rest . issues . listComments ( {
217
+ owner,
218
+ repo,
219
+ issue_number : prNumber ,
220
+ } ) ;
221
+
222
+ const botComment = comments . find ( comment =>
223
+ comment . body ?. includes ( '<!-- METAMASK-CODEOWNERS-BOT -->' )
224
+ ) ;
225
+
226
+ if ( botComment ) {
227
+ // Delete the existing comment
228
+ await octokit . rest . issues . deleteComment ( {
229
+ owner,
230
+ repo,
231
+ comment_id : botComment . id ,
232
+ } ) ;
233
+
234
+ console . log ( 'Deleted existing codeowners comment' ) ;
235
+ }
236
+ }
237
+
238
+ async function updateOrCreateComment (
239
+ octokit : InstanceType < typeof GitHub > ,
240
+ owner : string ,
241
+ repo : string ,
242
+ prNumber : number ,
243
+ commentBody : string
244
+ ) : Promise < void > {
245
+ // Get existing comments
246
+ const { data : comments } = await octokit . rest . issues . listComments ( {
247
+ owner,
248
+ repo,
249
+ issue_number : prNumber ,
250
+ } ) ;
251
+
252
+ const botComment = comments . find ( comment =>
253
+ comment . body ?. includes ( '<!-- METAMASK-CODEOWNERS-BOT -->' )
254
+ ) ;
255
+
256
+ if ( botComment ) {
257
+ // Simple text comparison is sufficient since we control both sides
258
+ if ( botComment . body !== commentBody ) {
259
+ await octokit . rest . issues . updateComment ( {
260
+ owner,
261
+ repo,
262
+ comment_id : botComment . id ,
263
+ body : commentBody ,
264
+ } ) ;
265
+
266
+ console . log ( 'Updated existing codeowners comment' ) ;
267
+ } else {
268
+ console . log ( 'No changes to codeowners, skipping comment update' ) ;
269
+ }
270
+ } else {
271
+ // Create new comment
272
+ await octokit . rest . issues . createComment ( {
273
+ owner,
274
+ repo,
275
+ issue_number : prNumber ,
276
+ body : commentBody ,
277
+ } ) ;
278
+
279
+ console . log ( 'Created new codeowners comment' ) ;
280
+ }
281
+ }
0 commit comments