11import { storage } from "./storage.js" ;
22import { resolve } from "path" ;
33import { PROJECT_ROOT_PATH } from "./config.js" ;
4+ import url from "node:url" ;
45
56export function parseUrlQueryParams ( urlString ) {
67 if ( ! urlString ) return urlString ;
78 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 ) ;
1012 return Object . fromEntries ( params . entries ( ) ) ;
1113 } catch ( err ) {
1214 console . error ( err ) ;
@@ -32,7 +34,7 @@ export function isCLARequired(pullRequest) {
3234 console . log ( "This PR is from a bot. So no CLA required." ) ;
3335 return false ;
3436 }
35- if ( ! isExternalContribution ( pullRequest ) ) {
37+ if ( ! isExternalContributionMaybe ( pullRequest ) ) {
3638 console . log ( "This PR is an internal contribution. So no CLA required." ) ;
3739 return false ;
3840 }
@@ -48,7 +50,7 @@ export function isMessageAfterMergeRequired(pullRequest) {
4850 console . log ( "This PR is from a bot. So no message after merge required." ) ;
4951 return false ;
5052 }
51- if ( ! isExternalContribution ( pullRequest ) ) {
53+ if ( ! isExternalContributionMaybe ( pullRequest ) ) {
5254 console . log (
5355 "This PR is an internal contribution. So no message after merge required." ,
5456 ) ;
@@ -57,13 +59,67 @@ export function isMessageAfterMergeRequired(pullRequest) {
5759 return true ;
5860}
5961
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 ) ;
6497 return true ;
6598 }
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 ;
67123}
68124
69125export function isABot ( user ) {
@@ -229,6 +285,7 @@ export function getMessage(name, context) {
229285}
230286
231287export function isCLASigned ( username ) {
288+ if ( ! username ) return
232289 const userData = storage . get ( { username : username , terms : "on" } ) ;
233290 if ( userData ?. length > 0 ) {
234291 return true ;
@@ -261,6 +318,17 @@ export function jsonToCSV(arr) {
261318 return csvRows . join ( '\n' ) ;
262319}
263320
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+ */
264332export async function getOctokitForOrg ( app , org ) {
265333 // Find the installation for the organization
266334 for await ( const { installation } of app . eachInstallation . iterator ( ) ) {
@@ -270,6 +338,12 @@ export async function getOctokitForOrg(app, org) {
270338 return octokit
271339 }
272340 }
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+ }
273347}
274348
275349export async function verifyGitHubAppAuthenticationAndAccess ( app ) {
@@ -330,7 +404,139 @@ function parseRepoUrl(repoUrl) {
330404
331405 return { owner : segments [ segments . length - 2 ] , repo : segments [ segments . length - 1 ] } ;
332406 } 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)
334408 return null ;
335409 }
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 ;
336542}
0 commit comments