diff --git a/.github/actions-scripts/fr-add-docs-reviewers-requests.js b/.github/actions-scripts/fr-add-docs-reviewers-requests.js new file mode 100644 index 000000000000..6e67b614d9f9 --- /dev/null +++ b/.github/actions-scripts/fr-add-docs-reviewers-requests.js @@ -0,0 +1,366 @@ +const { graphql } = require('@octokit/graphql') + +// Given a list of PR/issue node IDs and a project node ID, +// adds the PRs/issues to the project +// and returns the node IDs of the project items +async function addItemsToProject(items, project) { + console.log(`Adding ${items} to project ${project}`) + + const mutations = items.map( + (pr, index) => ` + pr_${index}: addProjectNextItem(input: { + projectId: $project + contentId: "${pr}" + }) { + projectNextItem { + id + } + } + ` + ) + + const mutation = ` + mutation($project:ID!) { + ${mutations.join(' ')} + } + ` + + const newItems = await graphql(mutation, { + project: project, + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + }) + + // The output of the mutation is + // {"pr_0":{"projectNextItem":{"id":ID!}},...} + // Pull out the ID for each new item + const newItemIDs = Object.entries(newItems).map((item) => item[1].projectNextItem.id) + + console.log(`New item IDs: ${newItemIDs}`) + + return newItemIDs +} + +// Given a list of project item node IDs and a list of corresponding authors +// generates a GraphQL mutation to populate: +// - "Status" (as "Ready for review" option) +// - "Date posted" (as today) +// - "Review due date" (as today + 2 weekdays) +// - "Feature" (as "OpenAPI schema update") +// - "Contributor type" (as "Hubber or partner" option) +// Does not populate "Review needs" or "Size" +function generateUpdateProjectNextItemFieldMutation(items, authors) { + // Formats a date object into the required format for projects + function formatDate(date) { + return date.getFullYear() + '-' + date.getMonth() + '-' + date.getDate() + } + + // Calculate 2 weekdays from now (excluding weekends; not considering holidays) + const datePosted = new Date() + let daysUntilDue + switch (datePosted.getDay()) { + case 0: // Sunday + daysUntilDue = 3 + break + case 6: // Saturday + daysUntilDue = 4 + break + default: + daysUntilDue = 2 + } + const millisecPerDay = 24 * 60 * 60 * 1000 + const dueDate = new Date(datePosted.getTime() + millisecPerDay * daysUntilDue) + + // Build the mutation for a single field + function generateMutation({ index, item, fieldID, value, literal = false }) { + let parsedValue + if (literal) { + parsedValue = `value: "${value}"` + } else { + parsedValue = `value: ${value}` + } + + return ` + set_${fieldID.substr(1)}_item_${index}: updateProjectNextItemField(input: { + projectId: $project + itemId: "${item}" + fieldId: ${fieldID} + ${parsedValue} + }) { + projectNextItem { + id + } + } + ` + } + + // Build the mutation for all fields for all items + const mutations = items.map( + (item, index) => ` + ${generateMutation({ + index: index, + item: item, + fieldID: '$statusID', + value: '$readyForReviewID', + })} + ${generateMutation({ + index: index, + item: item, + fieldID: '$datePostedID', + value: formatDate(datePosted), + literal: true, + })} + ${generateMutation({ + index: index, + item: item, + fieldID: '$reviewDueDateID', + value: formatDate(dueDate), + literal: true, + })} + ${generateMutation({ + index: index, + item: item, + fieldID: '$contributorTypeID', + value: '$hubberTypeID', + })} + ${generateMutation({ + index: index, + item: item, + fieldID: '$featureID', + value: 'OpenAPI schema update', + literal: true, + })} + ${generateMutation({ + index: index, + item: item, + fieldID: '$authorID', + value: authors[index], + literal: true, + })} + ` + ) + + // Build the full mutation + const mutation = ` + mutation( + $project: ID! + $statusID: ID! + $readyForReviewID: String! + $datePostedID: ID! + $reviewDueDateID: ID! + $contributorTypeID: ID! + $hubberTypeID: String! + $featureID: ID! + $authorID: ID! + + ) { + ${mutations.join(' ')} + } + ` + + return mutation +} + +async function run() { + // Get info about the docs-content review board project + // and about open github/github PRs + const data = await graphql( + ` + query ($organization: String!, $repo: String!, $projectNumber: Int!, $num_prs: Int!) { + organization(login: $organization) { + projectNext(number: $projectNumber) { + id + items(last: 100) { + nodes { + id + } + } + fields(first: 20) { + nodes { + id + name + settings + } + } + } + } + repository(name: $repo, owner: $organization) { + pullRequests(last: $num_prs, states: OPEN) { + nodes { + id + isDraft + reviewRequests(first: 10) { + nodes { + requestedReviewer { + ... on Team { + name + } + } + } + } + labels(first: 5) { + nodes { + name + } + } + reviews(first: 10) { + nodes { + onBehalfOf(first: 1) { + nodes { + name + } + } + } + } + author { + login + } + } + } + } + } + `, + { + organization: process.env.ORGANIZATION, + repo: process.env.REPO, + projectNumber: parseInt(process.env.PROJECT_NUMBER), + num_prs: parseInt(process.env.NUM_PRS), + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + } + ) + + // Get the PRs that are: + // - not draft + // - not a train + // - are requesting a review by docs-reviewers + // - have not already been reviewed on behalf of docs-reviewers + const prs = data.repository.pullRequests.nodes.filter( + (pr) => + !pr.isDraft && + !pr.labels.nodes.find((label) => label.name === 'Deploy train 🚂') && + pr.reviewRequests.nodes.find( + (requestedReviewers) => requestedReviewers.requestedReviewer.name === process.env.REVIEWER + ) && + !pr.reviews.nodes + .flatMap((review) => review.onBehalfOf.nodes) + .find((behalf) => behalf.name === process.env.REVIEWER) + ) + if (prs.length === 0) { + console.log('No PRs found. Exiting.') + return + } + + const prIDs = prs.map((pr) => pr.id) + const prAuthors = prs.map((pr) => pr.author.login) + console.log(`PRs found: ${prIDs}`) + + // Get the project ID + const projectID = data.organization.projectNext.id + + // Get the IDs of the last 100 items on the board. + // Until we have a way to check from a PR whether the PR is in a project, + // this is how we (roughly) avoid overwriting PRs that are already on the board. + // If we are overwriting items, query for more items. + const existingItemIDs = data.organization.projectNext.items.nodes.map((node) => node.id) + + function findFieldID(fieldName, data) { + const field = data.organization.projectNext.fields.nodes.find( + (field) => field.name === fieldName + ) + + if (field && field.id) { + return field.id + } else { + throw new Error( + `A field called "${fieldName}" was not found. Check if the field was renamed.` + ) + } + } + + function findSingleSelectID(singleSelectName, fieldName, data) { + const field = data.organization.projectNext.fields.nodes.find( + (field) => field.name === fieldName + ) + if (!field) { + throw new Error( + `A field called "${fieldName}" was not found. Check if the field was renamed.` + ) + } + + const singleSelect = JSON.parse(field.settings).options.find( + (field) => field.name === singleSelectName + ) + + if (singleSelect && singleSelect.id) { + return singleSelect.id + } else { + throw new Error( + `A single select called "${singleSelectName}" for the field "${fieldName}" was not found. Check if the single select was renamed.` + ) + } + } + + // Get the ID of the fields that we want to populate + const datePostedID = findFieldID('Date posted', data) + const reviewDueDateID = findFieldID('Review due date', data) + const statusID = findFieldID('Status', data) + const featureID = findFieldID('Feature', data) + const contributorTypeID = findFieldID('Contributor type', data) + const authorID = findFieldID('Author', data) + + // Get the ID of the single select values that we want to set + const readyForReviewID = findSingleSelectID('Ready for review', 'Status', data) + const hubberTypeID = findSingleSelectID('Hubber or partner', 'Contributor type', data) + + // Add the PRs to the project + const itemIDs = await addItemsToProject(prIDs, projectID) + + // If an item already existed on the project, the existing ID will be returned. + // Exclude existing items going forward. + // Until we have a way to check from a PR whether the PR is in a project, + // this is how we (roughly) avoid overwriting PRs that are already on the board + const newItemIDs = itemIDs.filter((id) => !existingItemIDs.includes(id)) + + if (newItemIDs.length === 0) { + console.log('All found PRs are already on the project. Exiting.') + return + } + + // Populate fields for the new project items + // Note: Since there is not a way to check if a PR is already on the board, + // this will overwrite the values of PRs that are on the board + const updateProjectNextItemMutation = generateUpdateProjectNextItemFieldMutation( + newItemIDs, + prAuthors + ) + console.log(`Populating fields for these items: ${newItemIDs}`) + + await graphql(updateProjectNextItemMutation, { + project: projectID, + statusID: statusID, + readyForReviewID: readyForReviewID, + datePostedID: datePostedID, + reviewDueDateID: reviewDueDateID, + contributorTypeID: contributorTypeID, + hubberTypeID: hubberTypeID, + featureID: featureID, + authorID: authorID, + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + }) + console.log('Done populating fields') + + return newItemIDs +} + +run().catch((error) => { + console.log(`#ERROR# ${error}`) + process.exit(1) +}) diff --git a/.github/actions-scripts/fr-add-docs-reviewers-requests.py b/.github/actions-scripts/fr-add-docs-reviewers-requests.py deleted file mode 100644 index 0dfd87fcf1dd..000000000000 --- a/.github/actions-scripts/fr-add-docs-reviewers-requests.py +++ /dev/null @@ -1,232 +0,0 @@ -# TODO: Convert to JavaScript for language consistency - -import json -import logging -import os -import requests - -# Constants -endpoint = 'https://api.github.com/graphql' - -# ID of the github/github repo -github_repo_id = "MDEwOlJlcG9zaXRvcnkz" - -# ID of the docs-reviewers team -docs_reviewers_id = "MDQ6VGVhbTQzMDMxMzk=" - -# ID of the "Docs content first responder" board -docs_project_id = "MDc6UHJvamVjdDQ1NzI0ODI=" - -# ID of the "OpenAPI review requests" column on the "Docs content first responder" board -docs_column_id = "PC_lAPNJr_OAEXFQs4A2OFq" - -# 100 is an educated guess of how many PRs are opened in a day on the github/github repo -# If we are missing PRs, either increase this number or increase the frequency at which this script is run -num_prs_to_search = 100 - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def find_open_prs_for_repo(repo_id: str, num_prs: int): - """Return data about a specified number of open PRs for a specified repo - - Arguments: - repo_id: The node ID of the repo to search - num_prs: The max number of PRs to return - - Returns: - Returns a JSON object of this structure: - { - "data": { - "node": { - "pullRequests": { - "nodes": [ - { - "id": str, - "isDraft": bool, - "reviewRequests": { - "nodes": [ - { - "requestedReviewer": { - "id": str - } - }... - ] - }, - "projectCards": { - "nodes": [ - { - "project": { - "id": str - } - }... - ] - } - }... - ] - } - } - } - } - """ - - query = """query ($repo_id: ID!, $num_prs: Int!) { - node(id: $repo_id) { - ... on Repository { - pullRequests(last: $num_prs, states: OPEN) { - nodes { - id - isDraft - reviewRequests(first: 10) { - nodes { - requestedReviewer { - ... on Team { - id - } - } - } - } - projectCards(first: 10) { - nodes { - project { - id - } - } - } - } - } - } - } - } - """ - - variables = { - "repo_id": github_repo_id, - "num_prs": num_prs - } - - response = requests.post( - endpoint, - json={'query': query, 'variables': variables}, - headers = {'Authorization': f"bearer {os.environ['TOKEN']}"} - ) - - response.raise_for_status() - - json_response = json.loads(response.text) - - if 'errors' in json_response: - raise RuntimeError(f'Error in GraphQL response: {json_response}') - - return json_response - -def add_prs_to_board(prs_to_add: list, column_id: str): - """Adds PRs to a column of a project board - - Arguments: - prs_to_add: A list of PR node IDs - column_id: The node ID of the column to add the PRs to - - Returns: - Nothing - """ - - logger.info(f"adding: {prs_to_add}") - - mutation = """mutation($pr_id: ID!, $column_id: ID!) { - addProjectCard(input:{contentId: $pr_id, projectColumnId: $column_id}) { - projectColumn { - name - } - } - }""" - - for pr_id in prs_to_add: - logger.info(f"Attempting to add {pr_id} to board") - - variables = { - "pr_id": pr_id, - "column_id": column_id - } - - response = requests.post( - endpoint, - json={'query': mutation, 'variables': variables}, - headers = {'Authorization': f"bearer {os.environ['TOKEN']}"} - ) - - json_response = json.loads(response.text) - - if 'errors' in json_response: - logger.info(f"GraphQL error when adding {pr_id}: {json_response}") - -def filter_prs(data, reviewer_id: str, project_id): - """Given data about the draft state, reviewers, and project boards for PRs, - return just the PRs that are: - - not draft - - are requesting a review for the specified team - - are not already on the specified project board - - Arguments: - data: A JSON object of this structure: - { - "data": { - "node": { - "pullRequests": { - "nodes": [ - { - "id": str, - "isDraft": bool, - "reviewRequests": { - "nodes": [ - { - "requestedReviewer": { - "id": str - } - }... - ] - }, - "projectCards": { - "nodes": [ - { - "project": { - "id": str - } - }... - ] - } - }... - ] - } - } - } - } - reviewer_id: The node ID of the reviewer to filter for - project_id: The project ID of the project to filter against - - Returns: - A list of node IDs of the PRs that met the requirements - """ - - pr_data = data['data']['node']['pullRequests']['nodes'] - - prs_to_add = [] - - for pr in pr_data: - if ( - not pr['isDraft'] and - reviewer_id in [req_rev['requestedReviewer']['id'] for req_rev in pr['reviewRequests']['nodes'] if req_rev['requestedReviewer']] and - project_id not in [proj_card['project']['id'] for proj_card in pr['projectCards']['nodes']] - ): - prs_to_add.append(pr['id']) - - return prs_to_add - -def main(): - query_data = find_open_prs_for_repo(github_repo_id, num_prs_to_search) - prs_to_add = filter_prs(query_data, docs_reviewers_id, docs_project_id) - add_prs_to_board(prs_to_add, docs_column_id) - -if __name__ == "__main__": - main() diff --git a/.github/allowed-actions.js b/.github/allowed-actions.js index be9ecab9232e..512595d49712 100644 --- a/.github/allowed-actions.js +++ b/.github/allowed-actions.js @@ -8,7 +8,6 @@ export default [ 'actions/github-script@2b34a689ec86a68d8ab9478298f91d5401337b7d', // v4.0.2 'actions/labeler@5f867a63be70efff62b767459b009290364495eb', // v2.2.0 'actions/setup-node@38d90ce44d5275ad62cc48384b3d8a58c500bb5f', // v2.2.0 - 'actions/setup-python@dc73133d4da04e56a135ae2246682783cc7c7cb6', // v2.2.2 'actions/stale@9d6f46564a515a9ea11e7762ab3957ee58ca50da', // v3.0.16 'alex-page/github-project-automation-plus@fdb7991b72040d611e1123d2b75ff10eda9372c9', 'andymckay/labeler@22d5392de2b725cea4b284df5824125054049d84', diff --git a/.github/workflows/docs-review-collect.yml b/.github/workflows/docs-review-collect.yml index 255774a38227..2f54a0ff629b 100644 --- a/.github/workflows/docs-review-collect.yml +++ b/.github/workflows/docs-review-collect.yml @@ -1,6 +1,6 @@ -name: Add docs-reviewers request to FR board +name: Add docs-reviewers request to the docs-content review board -# **What it does**: Adds PRs in github/github that requested a review from docs-reviewers to the FR board +# **What it does**: Adds PRs in github/github that requested a review from docs-reviewers to the docs-content review board # **Why we have it**: To catch docs-reviewers requests in github/github # **Who does it impact**: docs-content maintainers @@ -18,18 +18,18 @@ jobs: - name: Check out repo content uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - - name: Set up Python 3.9 - uses: actions/setup-python@dc73133d4da04e56a135ae2246682783cc7c7cb6 - with: - python-version: '3.9' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install requests + run: npm install @octokit/graphql - name: Run script run: | - python .github/actions-scripts/fr-add-docs-reviewers-requests.py + node .github/actions-scripts/fr-add-docs-reviewers-requests.js env: TOKEN: ${{ secrets.DOCS_BOT }} + PROJECT_NUMBER: 2936 + ORGANIZATION: 'github' + REPO: 'github' + REVIEWER: 'docs-reviewers' + # This is an educated guess of how many PRs are opened in a day on the github/github repo + # If we are missing PRs, either increase this number or increase the frequency at which this script is run + NUM_PRS: 100