From eb1abbbb25cced56a53c7515948e32f4ca29de2e Mon Sep 17 00:00:00 2001 From: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com> Date: Fri, 8 Dec 2023 15:52:27 -0800 Subject: [PATCH] Added Project Field Migration (#41970) This will ensure that we are also transferring the project data. --- .../src/commands/transfer-issues/index.ts | 285 +++++++++++++++--- 1 file changed, 239 insertions(+), 46 deletions(-) diff --git a/tools/monorepo-merge/src/commands/transfer-issues/index.ts b/tools/monorepo-merge/src/commands/transfer-issues/index.ts index cee53398be1..ca275133a91 100644 --- a/tools/monorepo-merge/src/commands/transfer-issues/index.ts +++ b/tools/monorepo-merge/src/commands/transfer-issues/index.ts @@ -5,12 +5,6 @@ import { CliUx, Command, Flags } from '@oclif/core'; import { graphql, GraphqlResponseError } from '@octokit/graphql'; import { RequestError } from '@octokit/request-error'; -/** - * Make sure we aren't hardcoding the monorepo into the command. - */ -const MONOREPO_OWNER = 'woocommerce'; -const MONOREPO_NAME = 'woocommerce'; - /** * Described a label object. */ @@ -19,17 +13,34 @@ interface GitHubLabel { name: string; } +// These properties should match the UpdateProjectV2ItemFieldValueInput mutation input. +interface GitHubProjectField { + projectId: string; + fieldId: string; + itemId: string; + dataType: string; + value: { + text?: string; + number?: number; + date?: string; + singleSelectOptionId?: string; + iterationId?: string; + }; +} + /** * Describes an issue object. */ interface GitHubIssue { id: string; + newID?: string; title: string; + projectFields: GitHubProjectField[]; } export default class TransferIssues extends Command { static description = - 'Transfers issues from another repository into the monorepo.'; + 'Transfers issues from another repository into the destination repository.'; static args = [ { @@ -40,6 +51,11 @@ export default class TransferIssues extends Command { ]; static flags = { + destination: Flags.string( { + description: + 'The destination repository to transfer into.', + default: 'woocommerce/woocommerce', + } ), searchFilter: Flags.string( { description: 'The search filter to apply when searching for issues to transfer.', @@ -57,6 +73,22 @@ export default class TransferIssues extends Command { async run(): Promise< void > { const { args, flags } = await this.parse( TransferIssues ); + const matches = flags.destination.match( /^([^/]+)\/([^/]+)$/ ); + if ( ! matches ) { + this.error( + 'The destination repository must be in the format "owner/repo"!' + ); + } + + const destinationOwner = matches[1]; + const destinationRepo = matches[2]; + + if ( ! args.source.startsWith( destinationOwner + '/' ) ) { + this.error( + 'The source and target repository must have the same owner!' + ); + } + let confirmation = await CliUx.ux.confirm( 'Are you sure you want to transfer issues from ' + args.source + @@ -91,10 +123,14 @@ export default class TransferIssues extends Command { } const monorepoNodeID = await this.getMonorepoNodeID( - authenticatedGraphQL + authenticatedGraphQL, + destinationOwner, + destinationRepo ); const labelsToAdd = await this.getLabelsToAdd( authenticatedGraphQL, + destinationOwner, + destinationRepo, flags.labels ); const issuesToTransfer = await this.getIssues( @@ -103,7 +139,7 @@ export default class TransferIssues extends Command { flags.searchFilter ); - const newIssueIDs: string[] = []; + let transferredIssues = 0; for ( const issue of issuesToTransfer ) { const newIssueID = await this.transferIssue( authenticatedGraphQL, @@ -111,8 +147,10 @@ export default class TransferIssues extends Command { monorepoNodeID ); - if ( newIssueID !== null ) { - newIssueIDs.push( newIssueID ); + // Track the transferred issue. + if ( newIssueID ) { + issue.newID = newIssueID; + transferredIssues++; } } @@ -123,11 +161,20 @@ export default class TransferIssues extends Command { await new Promise( ( resolve ) => setTimeout( resolve, 5000 ) ); CliUx.ux.action.stop(); - CliUx.ux.action.start( 'Applying label changes' ); - for ( const newIssueID of newIssueIDs ) { + CliUx.ux.action.start( 'Running post-transfer tasks' ); + for ( const issue of issuesToTransfer ) { + if ( ! issue.newID ) { + continue; + } + + this.updateProjectFields( + authenticatedGraphQL, + issue.projectFields + ); + this.addLabelsToIssue( authenticatedGraphQL, - newIssueID, + issue.newID, labelsToAdd ); } @@ -135,7 +182,7 @@ export default class TransferIssues extends Command { this.log( 'Successfully transferred ' + - newIssueIDs.length + + transferredIssues + '/' + numberOfIssues + ' issues.' @@ -184,9 +231,13 @@ export default class TransferIssues extends Command { * Fetches the node ID of the monorepo from GitHub. * * @param {graphql} authenticatedGraphQL The graphql object for making requests. + * @param {string} destinationOwner The owner of the repository to transfer issues into. + * @param {string} destinationRepo The repository to transfer issues into. */ private async getMonorepoNodeID( - authenticatedGraphQL: typeof graphql + authenticatedGraphQL: typeof graphql, + destinationOwner: string, + destinationRepo: string, ): Promise< string > { CliUx.ux.action.start( 'Finding Monorepo' ); @@ -206,8 +257,8 @@ export default class TransferIssues extends Command { } `, { - monorepoOwner: MONOREPO_OWNER, - monorepoName: MONOREPO_NAME, + monorepoOwner: destinationOwner, + monorepoName: destinationRepo, } ); @@ -219,11 +270,7 @@ export default class TransferIssues extends Command { if ( err instanceof GraphqlResponseError ) { this.error( - 'Could not find the repository "' + - MONOREPO_OWNER + - '/' + - MONOREPO_NAME + - '"' + 'Could not find the repository "' + destinationOwner + '/' + destinationRepo + '"' ); } @@ -237,10 +284,14 @@ export default class TransferIssues extends Command { * Gets all of the labels we want to add from GitHub. * * @param {graphql} authenticatedGraphQL The graphql object for making requests. + * @param {string} destinationOwner The owner of the repository to transfer issues into. + * @param {string} destinationRepo The repository to transfer issues into. * @param {Array.} labels The labels we want to add after the transfer. */ private async getLabelsToAdd( authenticatedGraphQL: typeof graphql, + destinationOwner: string, + destinationRepo: string, labels?: string ): Promise< GitHubLabel[] > { if ( ! labels ) { @@ -281,8 +332,8 @@ export default class TransferIssues extends Command { } `, { - monorepoOwner: MONOREPO_OWNER, - monorepoName: MONOREPO_NAME, + monorepoOwner: destinationOwner, + monorepoName: destinationRepo, cursor, } ); @@ -305,7 +356,7 @@ export default class TransferIssues extends Command { for ( const label of addLabels ) { if ( ! allLabels[ label ] ) { this.error( - 'The monorepo does not have the label ' + label + '.' + 'The target repository does not have the label ' + label + '.' ); } @@ -384,26 +435,83 @@ export default class TransferIssues extends Command { }; } >( ` - query ( $searchQuery: String!, $cursor: String ) { - search ( - type: ISSUE, - query: $searchQuery, - first: 100, - after: $cursor - ) { - nodes { - ... on Issue { - id, - title - } - }, - pageInfo { - hasNextPage, - endCursor - } - } - } - `, + query ($searchQuery: String!, $cursor: String) { + search(type: ISSUE, query: $searchQuery, first: 100, after: $cursor) { + nodes { + ... on Issue { + id + title + projectItems(first: 50) { + nodes { + id + project { + id + } + fieldValues(first: 50) { + nodes { + ... on ProjectV2ItemFieldTextValue { + field { + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + text + } + ... on ProjectV2ItemFieldNumberValue { + field { + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + number + } + ... on ProjectV2ItemFieldDateValue { + field { + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + date + } + ... on ProjectV2ItemFieldSingleSelectValue { + field { + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + optionId + } + ... on ProjectV2ItemFieldIterationValue { + field { + ... on ProjectV2FieldCommon { + id + name + dataType + } + } + iterationId + } + } + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + `, { searchQuery, cursor, @@ -412,9 +520,58 @@ export default class TransferIssues extends Command { // Record all of the issues that we've found for ( const issue of search.nodes ) { + const projectFields: GitHubProjectField[] = []; + + // @ts-expect-error The types do not cover the projectItems field. + for ( const projectItem of issue.projectItems.nodes ) { + for ( const fieldValue of projectItem.fieldValues.nodes ) { + // We only care if this is a field we can process. + if ( ! fieldValue.field ) { + continue; + } + + const projectField: GitHubProjectField = { + projectId: projectItem.project.id, + fieldId: fieldValue.field.id, + itemId: projectItem.id, + dataType: fieldValue.field.dataType, + value: {}, + }; + + // Map the field value to the correct property. + switch ( fieldValue.field.dataType ) { + case 'TEXT': + projectField.value = { text: fieldValue.text }; + break; + + case 'NUMBER': + projectField.value = { number: fieldValue.number }; + break; + + case 'DATE': + projectField.value = { date: fieldValue.date }; + break; + + case 'SINGLE_SELECT': + projectField.value = { singleSelectOptionId: fieldValue.optionId }; + break; + + case 'ITERATION': + projectField.value = { iterationId: fieldValue.iterationId }; + break; + + default: + continue; + } + + projectFields.push( projectField ); + } + } + issues.push( { id: issue.id, title: issue.title, + projectFields: projectFields } ); } @@ -483,6 +640,42 @@ export default class TransferIssues extends Command { } } + /** + * Update the project fields for the issue. + * + * @param {graphql} authenticatedGraphQL The graphql object for making requests. + * @param {Array.} projectFields The project fields to update for the issue. + */ + private async updateProjectFields( + authenticatedGraphQL: typeof graphql, + projectFields: GitHubProjectField[] + ) { + for ( const projectField of projectFields ) { + const input = { + clientMutationId: 'monorepo-merge', + projectId: projectField.projectId, + fieldId: projectField.fieldId, + itemId: projectField.itemId, + value: projectField.value, + }; + + await authenticatedGraphQL( + ` + mutation ( $input: UpdateProjectV2ItemFieldValueInput! ) { + updateProjectV2ItemFieldValue ( + input: $input + ) { + projectV2Item { + id + } + } + } + `, + { input } + ); + } + } + /** * Adds labels to an issue. *