From 841b05b504ede3afb0ab8eb3d3e28b60bca39528 Mon Sep 17 00:00:00 2001 From: Sarah Edwards Date: Thu, 12 Aug 2021 11:45:20 -0700 Subject: [PATCH] Add PRs that are labeled with ready-for-doc-review to review board (#20770) --- .../fr-add-docs-reviewers-requests.js | 258 +++--------------- .github/actions-scripts/projects.js | 239 ++++++++++++++++ .../actions-scripts/ready-for-docs-review.js | 102 +++++++ .github/workflows/ready-for-doc-review.yml | 30 +- 4 files changed, 404 insertions(+), 225 deletions(-) create mode 100644 .github/actions-scripts/projects.js create mode 100644 .github/actions-scripts/ready-for-docs-review.js diff --git a/.github/actions-scripts/fr-add-docs-reviewers-requests.js b/.github/actions-scripts/fr-add-docs-reviewers-requests.js index 03818775aa0b..facadfddc8e2 100644 --- a/.github/actions-scripts/fr-add-docs-reviewers-requests.js +++ b/.github/actions-scripts/fr-add-docs-reviewers-requests.js @@ -1,167 +1,12 @@ import { graphql } from '@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() + 1) + '-' + 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 -} +import { + addItemsToProject, + isDocsTeamMember, + findFieldID, + findSingleSelectID, + generateUpdateProjectNextItemFieldMutation, +} from './projects.js' async function run() { // Get info about the docs-content review board project @@ -268,43 +113,6 @@ async function run() { // 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) @@ -316,6 +124,7 @@ async function run() { // 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) + const docsMemberTypeID = findSingleSelectID('Docs team', 'Contributor type', data) // Add the PRs to the project const itemIDs = await addItemsToProject(prIDs, projectID) @@ -332,30 +141,33 @@ async function run() { } // 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') + // (Using for...of instead of forEach since the function uses await) + for (const [index, itemID] of newItemIDs.entries()) { + const updateProjectNextItemMutation = generateUpdateProjectNextItemFieldMutation({ + item: itemID, + author: prAuthors[index], + turnaround: 2, + }) + const contributorType = isDocsTeamMember(prAuthors[index]) ? docsMemberTypeID : hubberTypeID + console.log(`Populating fields for item: ${itemID}`) + + await graphql(updateProjectNextItemMutation, { + project: projectID, + statusID: statusID, + statusValueID: readyForReviewID, + datePostedID: datePostedID, + reviewDueDateID: reviewDueDateID, + contributorTypeID: contributorTypeID, + contributorType: contributorType, + featureID: featureID, + authorID: authorID, + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + }) + console.log('Done populating fields for item') + } return newItemIDs } diff --git a/.github/actions-scripts/projects.js b/.github/actions-scripts/projects.js new file mode 100644 index 000000000000..83d255436a5c --- /dev/null +++ b/.github/actions-scripts/projects.js @@ -0,0 +1,239 @@ +import { graphql } from '@octokit/graphql' + +// Shared functions for managing projects (memex) + +// Pull out the node ID of a project field +export 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.`) + } +} + +// Pull out the node ID of a single select field value +export 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.` + ) + } +} + +// 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 +export async function addItemsToProject(items, project) { + console.log(`Adding ${items} to project ${project}`) + + const mutations = items.map( + (item, index) => ` + item_${index}: addProjectNextItem(input: { + projectId: $project + contentId: "${item}" + }) { + 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 + // {"item_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 +} + +export async function addItemToProject(item, project) { + const newItemIDs = await addItemsToProject([item], project) + + const newItemID = newItemIDs[0] + + return newItemID +} + +// Given a GitHub login, returns a bool indicating +// whether the login is part of the docs team +export async function isDocsTeamMember(login) { + // Get all members of the docs team + const data = await graphql( + ` + query { + organization(login: "github") { + team(slug: "docs") { + members { + nodes { + login + } + } + } + } + } + `, + { + headers: { + authorization: `token ${process.env.TOKEN}`, + }, + } + ) + + const teamMembers = data.organization.team.members.nodes.map((entry) => entry.login) + + return teamMembers.includes(login) +} + +// Formats a date object into the required format for projects +export function formatDateForProject(date) { + return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() +} + +// Given a date object and optional turnaround time +// Calculate the date {turnaround} business days from now +// (excluding weekends; not considering holidays) +export function calculateDueDate(datePosted, turnaround = 2) { + let daysUntilDue + switch (datePosted.getDay()) { + case 0: // Sunday + daysUntilDue = turnaround + 1 + break + case 6: // Saturday + daysUntilDue = turnaround + 2 + break + default: + daysUntilDue = turnaround + } + const millisecPerDay = 24 * 60 * 60 * 1000 + const dueDate = new Date(datePosted.getTime() + millisecPerDay * daysUntilDue) + return dueDate +} + +// Given a project item node ID and author login +// generates a GraphQL mutation to populate: +// - "Status" (as variable passed with the request) +// - "Date posted" (as today) +// - "Review due date" (as today + {turnaround} weekdays) +// - "Contributor type" (as variable passed with the request) +// - "Feature" (as {feature}) +// - "Author" (as {author})" +export function generateUpdateProjectNextItemFieldMutation({ + item, + author, + turnaround = 2, + feature = '', +}) { + const datePosted = new Date() + const dueDate = calculateDueDate(datePosted, turnaround) + + // Build the mutation to update a single project field + // Specify literal=true to indicate that the value should be used as a string, not a variable + function generateMutationToUpdateField({ item, fieldID, value, literal = false }) { + const parsedValue = literal ? `value: "${value}"` : `value: ${value}` + + return ` + set_${fieldID.substr(1)}_item_${item}: updateProjectNextItemField(input: { + projectId: $project + itemId: "${item}" + fieldId: ${fieldID} + ${parsedValue} + }) { + projectNextItem { + id + } + } + ` + } + + const mutation = ` + mutation( + $project: ID! + $statusID: ID! + $statusValueID: String! + $datePostedID: ID! + $reviewDueDateID: ID! + $contributorTypeID: ID! + $contributorType: String! + $featureID: ID! + $authorID: ID! + ) { + ${generateMutationToUpdateField({ + item: item, + fieldID: '$statusID', + value: '$statusValueID', + })} + ${generateMutationToUpdateField({ + item: item, + fieldID: '$datePostedID', + value: formatDateForProject(datePosted), + literal: true, + })} + ${generateMutationToUpdateField({ + item: item, + fieldID: '$reviewDueDateID', + value: formatDateForProject(dueDate), + literal: true, + })} + ${generateMutationToUpdateField({ + item: item, + fieldID: '$contributorTypeID', + value: '$contributorType', + })} + ${generateMutationToUpdateField({ + item: item, + fieldID: '$featureID', + value: feature, + literal: true, + })} + ${generateMutationToUpdateField({ + item: item, + fieldID: '$authorID', + value: author, + literal: true, + })} + } + ` + + return mutation +} + +export default { + addItemsToProject, + addItemToProject, + isDocsTeamMember, + findFieldID, + findSingleSelectID, + formatDateForProject, + calculateDueDate, + generateUpdateProjectNextItemFieldMutation, +} diff --git a/.github/actions-scripts/ready-for-docs-review.js b/.github/actions-scripts/ready-for-docs-review.js new file mode 100644 index 000000000000..79aa7607a0ba --- /dev/null +++ b/.github/actions-scripts/ready-for-docs-review.js @@ -0,0 +1,102 @@ +import { graphql } from '@octokit/graphql' + +import { + addItemToProject, + isDocsTeamMember, + findFieldID, + findSingleSelectID, + generateUpdateProjectNextItemFieldMutation, +} from './projects.js' + +async function run() { + // Get info about the docs-content review board project + const data = await graphql( + ` + query ($organization: String!, $projectNumber: Int!) { + organization(login: $organization) { + projectNext(number: $projectNumber) { + id + fields(first: 20) { + nodes { + id + name + settings + } + } + } + } + } + `, + { + organization: process.env.ORGANIZATION, + projectNumber: parseInt(process.env.PROJECT_NUMBER), + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + } + ) + + // Get the project ID + const projectID = data.organization.projectNext.id + + // 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) + const docsMemberTypeID = findSingleSelectID('Docs team', 'Contributor type', data) + const osContributorTypeID = findSingleSelectID('OS contributor', 'Contributor type', data) + + // Add the PR to the project + const newItemID = await addItemToProject(process.env.PR_NODE_ID, projectID) + + // Generate a mutation to populate fields for the new project item + const updateProjectNextItemMutation = generateUpdateProjectNextItemFieldMutation({ + item: newItemID, + author: process.env.AUTHOR_LOGIN, + turnaround: 2, + }) + + // Determine which variable to use for the contributor type + let contributorType + if (isDocsTeamMember(process.env.AUTHOR_LOGIN)) { + contributorType = docsMemberTypeID + } else if (process.env.PR_REPO === 'github/docs') { + contributorType = osContributorTypeID + } else { + contributorType = hubberTypeID + } + + console.log(`Populating fields for item: ${newItemID}`) + + await graphql(updateProjectNextItemMutation, { + project: projectID, + statusID: statusID, + statusValueID: readyForReviewID, + datePostedID: datePostedID, + reviewDueDateID: reviewDueDateID, + contributorTypeID: contributorTypeID, + contributorType: contributorType, + featureID: featureID, + authorID: authorID, + headers: { + authorization: `token ${process.env.TOKEN}`, + 'GraphQL-Features': 'projects_next_graphql', + }, + }) + console.log('Done populating fields for item') + + return newItemID +} + +run().catch((error) => { + console.log(`#ERROR# ${error}`) + process.exit(1) +}) diff --git a/.github/workflows/ready-for-doc-review.yml b/.github/workflows/ready-for-doc-review.yml index 74243dac5507..f32c520432c5 100644 --- a/.github/workflows/ready-for-doc-review.yml +++ b/.github/workflows/ready-for-doc-review.yml @@ -1,6 +1,6 @@ name: Ready for docs-content review -# **What it does**: Adds pull requests in the docs-internal repository to the docs-content first responder project board +# **What it does**: Adds pull requests in the docs-internal repository to the docs-content review board when the "ready-for-doc-review" label is added # **Why we have it**: So that other GitHub teams can easily request reviews from the docs-content team, and so that writers can see when a PR is ready for review # **Who does it impact**: Writers working in the docs-internal repository @@ -14,7 +14,33 @@ jobs: if: github.event.label.name == 'ready-for-doc-review' && github.repository == 'github/docs-internal' runs-on: ubuntu-latest steps: - - name: Add pull request to FR project board + - name: Check out repo content + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + + - name: Setup Node + uses: actions/setup-node@38d90ce44d5275ad62cc48384b3d8a58c500bb5f + with: + node-version: 16.x + cache: npm + + - name: Install dependencies + run: npm install @octokit/graphql + + - name: Run script + run: | + node .github/actions-scripts/ready-for-docs-review.js + env: + TOKEN: ${{ secrets.DOCS_BOT }} + PROJECT_NUMBER: 2936 + ORGANIZATION: 'github' + PR_NODE_ID: ${{ github.event.pull_request.node_id }} + AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} + PR_REPO: ${{ github.event.pull_request.base.repo.full_name }} + + # Since the projects API is still in beta, use the old workflow if something fails + # so that we don't miss these PRs + - name: Backup action (Add pull request to FR project board) + if: ${{ failure() }} uses: rachmari/actions-add-new-issue-to-column@1a459ef92308ba7c9c9dc2fcdd72f232495574a9 with: action-token: ${{ secrets.DOCUBOT_FR_PROJECT_BOARD_WORKFLOWS_REPO_ORG_READ_SCOPES }}