// Description: // GtHub-related helpers // // Dependencies: // github: "^13.1.0" // // Author: // PombeirP module.exports = { getPullRequestReviewStates: _getPullRequestReviewStates, getReviewApprovalState: _getReviewApprovalState, getProjectCardForIssue: _getProjectCardForIssue, getOrgProjectByName: _getOrgProjectByName, getRepoProjectByName: _getRepoProjectByName, getProjectColumnByName: _getProjectColumnByName, getPullRequestCurrentStatusForContext: _getPullRequestCurrentStatusForContext } async function _getPullRequestReviewStates (github, prInfo) { let finalReviewsMap = new Map() const ghreviews = await github.paginate( github.pullRequests.listReviews({ ...prInfo, per_page: 100 }), res => res.data) for (const review of ghreviews) { switch (review.state) { case 'APPROVED': case 'CHANGES_REQUESTED': case 'PENDING': finalReviewsMap.set(review.user.id, review.state) break } } return Array.from(finalReviewsMap.values()) } async function _getReviewApprovalState (context, robot, prInfo, minReviewers, testedPullRequestLabelName, filterIgnoredStatusContextFn) { const { github } = context // Get detailed pull request const pullRequestPayload = await github.pullRequests.get(prInfo) const pullRequest = pullRequestPayload.data context.payload.pull_request = pullRequest if (pullRequest.mergeable !== null && pullRequest.mergeable !== undefined && !pullRequest.mergeable) { robot.log.debug(`pullRequest.mergeable is ${pullRequest.mergeable}, considering as failed`) return 'failed' } let state switch (pullRequest.mergeable_state) { case 'clean': if (testedPullRequestLabelName !== null && pullRequest.labels.find(label => label.name === testedPullRequestLabelName)) { robot.log.debug(`Pull request is labeled '${testedPullRequestLabelName}', ignoring`) return null } state = 'approved' break case 'dirty': state = 'failed' break case 'unstable': if (filterIgnoredStatusContextFn) { const isSuccess = await _isPullRequestStatusSuccessIgnoringContext(context, filterIgnoredStatusContextFn, pullRequest) if (isSuccess) { state = 'approved' robot.log.debug(`All important statuses are successful, so considering state as ${state}`) } } break } robot.log.debug(`pullRequest.mergeable_state is ${pullRequest.mergeable_state}, considering state as ${state}`) if (state !== 'approved') { return state } const threshold = minReviewers // Minimum number of approvers const finalReviews = await _getPullRequestReviewStates(github, prInfo) robot.log.debug(finalReviews) const approvedReviews = finalReviews.filter(reviewState => reviewState === 'APPROVED') if (approvedReviews.length >= threshold) { const reviewsWithChangesRequested = finalReviews.filter(reviewState => reviewState === 'CHANGES_REQUESTED') if (reviewsWithChangesRequested.length === 0) { robot.log.debug(`No changes requested, considering state as approved`) return 'approved' } robot.log.debug(`${reviewsWithChangesRequested.length} changes requested, considering state as changes_requested`) return 'changes_requested' } robot.log.debug(`Not enough reviewers yet, considering state as awaiting_reviewers`) return 'awaiting_reviewers' } async function _getProjectCardForIssue (github, columnId, issueUrl) { const ghcardsPayload = await github.projects.listCards({ column_id: columnId }) const ghcard = ghcardsPayload.data.find(c => c.content_url === issueUrl) return ghcard } async function _getOrgProjectByName (github, robot, orgName, projectName, botName) { if (!projectName) { return null } try { // Fetch org projects // TODO: The org project and project column info should be cached // in order to improve performance and reduce roundtrips const ghprojectsPayload = await github.projects.getOrgProjects({ org: orgName, state: 'open' }) const project = ghprojectsPayload.data.find(p => p.name === projectName) if (!project) { robot.log.error(`${botName} - Couldn't find project ${projectName} in ${orgName} org`) return null } robot.log.debug(`${botName} - Fetched ${project.name} project (${project.id})`) return project } catch (err) { robot.log.error(`${botName} - Couldn't fetch the github projects for org`, orgName, err) return null } } async function _getRepoProjectByName (github, robot, repoInfo, projectName, botName) { if (!projectName) { return null } try { const ghprojectsPayload = await github.projects.listForRepo({ ...repoInfo, state: 'open' }) const project = ghprojectsPayload.data.find(p => p.name === projectName) if (!project) { robot.log.error(`${botName} - Couldn't find project ${projectName} in repo ${repoInfo.owner}/${repoInfo.repo}`) return null } robot.log.debug(`${botName} - Fetched ${project.name} project (${project.id})`) return project } catch (err) { robot.log.error(`${botName} - Couldn't fetch the github projects for repo: ${err}`, repoInfo) return null } } async function _getProjectColumnByName (github, robot, project, columnName, botName) { if (!project) { return null } if (!columnName) { return null } try { const ghcolumnsPayload = await github.projects.listColumns({ project_id: project.id }) const column = ghcolumnsPayload.data.find(c => c.name === columnName) if (!column) { robot.log.error(`${botName} - Couldn't find ${columnName} column in project ${project.name}`) return null } robot.log.debug(`${botName} - Fetched ${column.name} column (${column.id})`) return column } catch (err) { robot.log.error(`${botName} - Couldn't fetch the github columns for project: ${err}`, project.id) return null } } async function _getPullRequestCurrentStatusForContext (context, statusContext, pullRequest) { if (!pullRequest) { pullRequest = context.payload.pull_request } const { data: { statuses } } = await context.github.repos.getCombinedStatusForRef(context.repo({ ref: pullRequest.head.sha })) return (statuses.find(status => status.context === statusContext) || {}).state } async function _isPullRequestStatusSuccessIgnoringContext (context, filterIgnoredStatusContextFn, pullRequest) { if (!pullRequest) { pullRequest = context.payload.pull_request } const statuses = await context.github.paginate( context.github.repos.listStatusesForRef(context.repo({ ref: pullRequest.head.sha, per_page: 100 })), res => res.data) const contexts = {} for (let i = statuses.length - 1; i >= 0; i--) { const status = statuses[i] if (filterIgnoredStatusContextFn(status)) { contexts[status.context] = status.state } } let isSuccess = true for (const context in contexts) { if (contexts.hasOwnProperty(context)) { const state = contexts[context] switch (state) { case 'pending': case 'error': isSuccess = false break } if (!isSuccess) { break } } } return isSuccess }