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