1
1
import { storage } from "./storage.js" ;
2
2
import { resolve } from "path" ;
3
3
import { PROJECT_ROOT_PATH } from "./config.js" ;
4
+ import url from "node:url" ;
4
5
5
6
export function parseUrlQueryParams ( urlString ) {
6
7
if ( ! urlString ) return urlString ;
7
8
try {
8
- const url = new URL ( urlString ) ;
9
- const params = new URLSearchParams ( url . search ) ;
9
+ const parsedUrl = url . parse ( urlString )
10
+ const query = parsedUrl . query ;
11
+ const params = new URLSearchParams ( query ) ;
10
12
return Object . fromEntries ( params . entries ( ) ) ;
11
13
} catch ( err ) {
12
14
console . error ( err ) ;
@@ -32,7 +34,7 @@ export function isCLARequired(pullRequest) {
32
34
console . log ( "This PR is from a bot. So no CLA required." ) ;
33
35
return false ;
34
36
}
35
- if ( ! isExternalContribution ( pullRequest ) ) {
37
+ if ( ! isExternalContributionMaybe ( pullRequest ) ) {
36
38
console . log ( "This PR is an internal contribution. So no CLA required." ) ;
37
39
return false ;
38
40
}
@@ -48,7 +50,7 @@ export function isMessageAfterMergeRequired(pullRequest) {
48
50
console . log ( "This PR is from a bot. So no message after merge required." ) ;
49
51
return false ;
50
52
}
51
- if ( ! isExternalContribution ( pullRequest ) ) {
53
+ if ( ! isExternalContributionMaybe ( pullRequest ) ) {
52
54
console . log (
53
55
"This PR is an internal contribution. So no message after merge required." ,
54
56
) ;
@@ -57,13 +59,67 @@ export function isMessageAfterMergeRequired(pullRequest) {
57
59
return true ;
58
60
}
59
61
60
- export function isExternalContribution ( pullRequest ) {
61
- if (
62
- pullRequest ?. head ?. repo ?. full_name !== pullRequest ?. base ?. repo ?. full_name
63
- ) {
62
+ /**
63
+ * Whether a pull request is a contribution by external user who has bot been associated with the repo
64
+ * @param {Object } pullRequest
65
+ * @returns {boolean | undefined } - boolean when confirmed, undefined when not confirmed
66
+ */
67
+ export function isExternalContributionMaybe ( pullRequest ) {
68
+ const { owner, repo } = parseRepoUrl ( pullRequest ?. repository_url || pullRequest ?. base ?. repo ?. html_url ) || { } ;
69
+ const username = pullRequest ?. user ?. login ;
70
+ if ( typeof pullRequest ?. author_association === "string" ) {
71
+ // OWNER: Author is the owner of the repository.
72
+ // MEMBER: Author is a member of the organization that owns the repository.
73
+ // COLLABORATOR: Author has been invited to collaborate on the repository.
74
+ // CONTRIBUTOR: Author has previously committed to the repository.
75
+ // FIRST_TIMER: Author has not previously committed to GitHub.
76
+ // FIRST_TIME_CONTRIBUTOR: Author has not previously committed to the repository.
77
+ // MANNEQUIN: Author is a placeholder for an unclaimed user.
78
+ // NONE: Author has no association with the repository (or doesn't want to make his association public).
79
+ switch ( pullRequest . author_association . toUpperCase ( ) ) {
80
+ case "OWNER" :
81
+ storage . cache . set ( false , username , "contribution" , "external" , owner , repo ) ;
82
+ return false ;
83
+ case "MEMBER" :
84
+ storage . cache . set ( false , username , "contribution" , "external" , owner , repo ) ;
85
+ return false ;
86
+ case "COLLABORATOR" :
87
+ pullRequest . isExternalContribution = false ;
88
+ storage . cache . set ( false , username , "contribution" , "external" , owner , repo ) ;
89
+ return false ;
90
+ default :
91
+ //Will need more checks to verify author relation with the repo
92
+ break ;
93
+ }
94
+ }
95
+ if ( pullRequest ?. head ?. repo ?. full_name !== pullRequest ?. base ?. repo ?. full_name ) {
96
+ storage . cache . set ( true , username , "contribution" , "external" , owner , repo ) ;
64
97
return true ;
65
98
}
66
- return false ;
99
+ // Utilize cache if possible
100
+ const isConfirmedToBeExternalContributionInPast = storage . cache . get ( username , "contribution" , "external" , owner , repo ) ;
101
+ if ( typeof isConfirmedToBeExternalContributionInPast === "boolean" ) {
102
+ return isConfirmedToBeExternalContributionInPast
103
+ }
104
+ // Ambigous results after this point.
105
+ // Cannot confirm whether an external contribution or not.
106
+ // Need more reliable check.
107
+ return undefined ;
108
+ }
109
+
110
+ async function isExternalContribution ( octokit , pullRequest ) {
111
+ const probablisticResult = isExternalContributionMaybe ( pullRequest ) ;
112
+ if ( typeof probablisticResult === "boolean" ) {
113
+ // Boolean is returned when the probabilistic check is sufficient
114
+ return probablisticResult ;
115
+ }
116
+ const username = pullRequest ?. user ?. login ;
117
+ const { owner, repo } = parseRepoUrl ( pullRequest ?. repository_url || pullRequest ?. base ?. repo ?. html_url ) || { } ;
118
+ //TODO: Handle failure in checking permissions for the user
119
+ const deterministicPermissionCheck = await isAllowedToWriteToTheRepo ( octokit , username , owner , repo ) ;
120
+ pullRequest . isExternalContribution = deterministicPermissionCheck ;
121
+ storage . cache . set ( pullRequest , username , "contribution" , "external" , owner , repo ) ;
122
+ return deterministicPermissionCheck ;
67
123
}
68
124
69
125
export function isABot ( user ) {
@@ -229,6 +285,7 @@ export function getMessage(name, context) {
229
285
}
230
286
231
287
export function isCLASigned ( username ) {
288
+ if ( ! username ) return
232
289
const userData = storage . get ( { username : username , terms : "on" } ) ;
233
290
if ( userData ?. length > 0 ) {
234
291
return true ;
@@ -261,6 +318,17 @@ export function jsonToCSV(arr) {
261
318
return csvRows . join ( '\n' ) ;
262
319
}
263
320
321
+ /**
322
+ * Authenticate as app installation for the org
323
+ * Authenticating as an app installation lets your app access resources that are owned by the user or organization
324
+ * that installed the app. Authenticating as an app installation is ideal for automation workflows
325
+ * that don't involve user input.
326
+ * Check out { @link https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app GitHub Docs for Authentication }
327
+ * and { @tutorial https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation GitHub Docs for Authenticating App Installation}
328
+ * @param {Object } app
329
+ * @param {string } org
330
+ * @returns
331
+ */
264
332
export async function getOctokitForOrg ( app , org ) {
265
333
// Find the installation for the organization
266
334
for await ( const { installation } of app . eachInstallation . iterator ( ) ) {
@@ -270,6 +338,12 @@ export async function getOctokitForOrg(app, org) {
270
338
return octokit
271
339
}
272
340
}
341
+ console . error ( "No GitHub App installation found for " + org ) ;
342
+ // Fall back authentication method
343
+ const DEFAULT_GITHUB_ORG = process . env . DEFAULT_GITHUB_ORG ;
344
+ if ( DEFAULT_GITHUB_ORG && org !== DEFAULT_GITHUB_ORG ) {
345
+ return await getOctokitForOrg ( app , DEFAULT_GITHUB_ORG ) ;
346
+ }
273
347
}
274
348
275
349
export async function verifyGitHubAppAuthenticationAndAccess ( app ) {
@@ -330,7 +404,139 @@ function parseRepoUrl(repoUrl) {
330
404
331
405
return { owner : segments [ segments . length - 2 ] , repo : segments [ segments . length - 1 ] } ;
332
406
} catch ( error ) {
333
- // Handle cases where URL constructor fails (e.g., SSH URLs)
407
+ //TODO: Handle cases where URL constructor fails (e.g., SSH URLs)
334
408
return null ;
335
409
}
410
+ }
411
+
412
+ export async function getOpenPullRequests ( octokit , owner , repo , options ) {
413
+ let query = `is:pr is:open` + ( repo ? ` repo:${ owner + "/" + repo } ` : ` org:${ owner } ` ) ;
414
+ const BOT_USERS = process . env . GITHUB_BOT_USERS ? process . env . GITHUB_BOT_USERS . split ( "," ) ?. map ( ( item ) => item ?. trim ( ) ) : null ;
415
+ const GITHUB_ORG_MEMBERS = process . env . GITHUB_ORG_MEMBERS ? process . env . GITHUB_ORG_MEMBERS . split ( "," ) ?. map ( ( item ) => item ?. trim ( ) ) : null ;
416
+ // Remove results from bots or internal team members
417
+ BOT_USERS ?. forEach ( ( botUser ) => query += ( " -author:" + botUser ) ) ;
418
+ GITHUB_ORG_MEMBERS ?. forEach ( ( orgMember ) => query += ( " -author:" + orgMember ) ) ;
419
+ const response = await octokit . rest . search . issuesAndPullRequests ( {
420
+ q : query ,
421
+ per_page : 100 ,
422
+ page : options ?. page || 1 ,
423
+ sort : 'created' ,
424
+ order : 'desc'
425
+ } ) ;
426
+ console . log ( response ?. data ?. total_count + " results found for search: " + query ) ;
427
+ const humanPRs = response ?. data ?. items ?. filter ( pr => pr . user && pr . user . type === 'User' ) ;
428
+ return humanPRs ;
429
+ }
430
+
431
+ export async function getOpenExternalPullRequests ( app , owner , repo , options ) {
432
+ try {
433
+ const octokit = await getOctokitForOrg ( app , owner ) ;
434
+ if ( ! octokit ) {
435
+ throw new Error ( "Failed to search PR because of undefined octokit intance" )
436
+ }
437
+ const openPRs = await getOpenPullRequests ( octokit , owner , repo , options ) ;
438
+ if ( ! Array . isArray ( openPRs ) ) {
439
+ return ;
440
+ }
441
+ // Send only the external PRs
442
+ const openExternalPRs = [ ]
443
+ for ( const pr of openPRs ) {
444
+ try {
445
+ pr . isExternalContribution = await isExternalContribution ( octokit , pr ) ;
446
+ if ( pr . isExternalContribution ) {
447
+ openExternalPRs . push ( pr ) ;
448
+ }
449
+ } catch ( err ) {
450
+ // Some error occurred, so we cannot deterministically say whether it is an external contribution or not
451
+ pr . isExternalContribution = undefined ;
452
+ // We are anyways going to send this in the external open PR list
453
+ openExternalPRs . push ( pr ) ;
454
+ }
455
+ }
456
+ return openExternalPRs
457
+ } catch ( err ) {
458
+ return
459
+ }
460
+ }
461
+
462
+ export function timeAgo ( date ) {
463
+ if ( ! date ) return '' ;
464
+ if ( typeof date === 'string' ) {
465
+ date = new Date ( date ) ;
466
+ }
467
+ const now = new Date ( ) ;
468
+ const seconds = Math . floor ( ( now - date ) / 1000 ) ;
469
+ let interval = Math . floor ( seconds / 31536000 ) ;
470
+
471
+ if ( interval > 1 ) {
472
+ return `${ interval } years ago` ;
473
+ }
474
+ interval = Math . floor ( seconds / 2592000 ) ;
475
+ if ( interval > 1 ) {
476
+ return `${ interval } months ago` ;
477
+ }
478
+ interval = Math . floor ( seconds / 604800 ) ;
479
+ if ( interval > 1 ) {
480
+ return `${ interval } weeks ago` ;
481
+ }
482
+ interval = Math . floor ( seconds / 86400 ) ;
483
+ if ( interval > 1 ) {
484
+ return `${ interval } days ago` ;
485
+ }
486
+ interval = Math . floor ( seconds / 3600 ) ;
487
+ if ( interval > 1 ) {
488
+ return `${ interval } hours ago` ;
489
+ }
490
+ interval = Math . floor ( seconds / 60 ) ;
491
+ if ( interval > 1 ) {
492
+ return `${ interval } minutes ago` ;
493
+ }
494
+ return `${ seconds } seconds ago` ;
495
+ }
496
+
497
+ /**
498
+ * Check user permissions for a repository
499
+ * The authenticating octokit instance must have "Metadata" repository permissions (read)
500
+ * @param {string } username
501
+ * @param {string } owner
502
+ * @param {string } repo
503
+ * @returns {boolean }
504
+ */
505
+ async function isAllowedToWriteToTheRepo ( octokit , username , owner , repo , ) {
506
+ try {
507
+ const result = await octokit . rest . repos . getCollaboratorPermissionLevel ( {
508
+ owner,
509
+ repo,
510
+ username,
511
+ } ) ;
512
+ if ( [ "admin" , "write" ] . includes ( result ?. permission ) ) {
513
+ return true
514
+ }
515
+ if ( [ "admin" , "maintain" , "write" ] . includes ( result ?. role_name ) ) {
516
+ return true
517
+ }
518
+ return false ;
519
+ } catch ( err ) {
520
+ // If 403 error "HttpError: Resource not accessible by integration"
521
+ // The app is not installed in that repo
522
+ // Only "metadata:repository" permission is needed for this api, which all gh apps have wherever they are installed
523
+ console . log ( "Failed to check if a " + username + " is allowed to write to " + owner + "/" + repo ) ;
524
+ console . error ( err ) ;
525
+ throw new Error ( "Failed to check user permission for the repo" )
526
+ }
527
+ }
528
+
529
+ export async function getPullRequestDetail ( app , owner , repo , number ) {
530
+ const octokit = await getOctokitForOrg ( app , owner ) ;
531
+ if ( ! octokit ) {
532
+ throw new Error ( "Failed to search PR because of undefined octokit intance" )
533
+ }
534
+ const { data } = await octokit . rest . pulls . get ( {
535
+ owner : owner ,
536
+ repo : repo ,
537
+ pull_number : number
538
+ } ) ;
539
+ if ( ! data ) return data ;
540
+ const pr = Object . assign ( { } , data , { isExternalContribution : isExternalContributionMaybe ( data ) } ) ;
541
+ return pr ;
336
542
}
0 commit comments