Added Issue Transfer Command Skeleton

This commit adds the skeleton for an issue transfer command.
Almost all of the functionality is implemented, but it is missing
the actual transfer of issues. I'm nervous about testing this, so
I'm going to implement the rest of the functionality first.
This commit is contained in:
Christopher Allford 2022-03-16 22:53:30 -07:00
parent 1a29e3861c
commit 57ab538668
4 changed files with 227 additions and 12 deletions

View File

@ -382,6 +382,7 @@ importers:
'@oclif/core': ^1 '@oclif/core': ^1
'@oclif/plugin-help': ^5 '@oclif/plugin-help': ^5
'@oclif/plugin-plugins': ^2.0.1 '@oclif/plugin-plugins': ^2.0.1
'@octokit/graphql': 4.8.0
'@types/node': ^16.9.4 '@types/node': ^16.9.4
eslint: ^7.32.0 eslint: ^7.32.0
globby: ^11 globby: ^11
@ -394,6 +395,7 @@ importers:
'@oclif/core': 1.3.4 '@oclif/core': 1.3.4
'@oclif/plugin-help': 5.1.11 '@oclif/plugin-help': 5.1.11
'@oclif/plugin-plugins': 2.1.0 '@oclif/plugin-plugins': 2.1.0
'@octokit/graphql': 4.8.0
devDependencies: devDependencies:
'@types/node': 16.10.3 '@types/node': 16.10.3
eslint: 7.32.0 eslint: 7.32.0
@ -4025,7 +4027,6 @@ packages:
'@octokit/types': 6.34.0 '@octokit/types': 6.34.0
is-plain-object: 5.0.0 is-plain-object: 5.0.0
universal-user-agent: 6.0.0 universal-user-agent: 6.0.0
dev: true
/@octokit/graphql/4.8.0: /@octokit/graphql/4.8.0:
resolution: {integrity: sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==} resolution: {integrity: sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==}
@ -4035,11 +4036,9 @@ packages:
universal-user-agent: 6.0.0 universal-user-agent: 6.0.0
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
dev: true
/@octokit/openapi-types/11.2.0: /@octokit/openapi-types/11.2.0:
resolution: {integrity: sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==} resolution: {integrity: sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==}
dev: true
/@octokit/plugin-paginate-rest/2.17.0_@octokit+core@3.5.1: /@octokit/plugin-paginate-rest/2.17.0_@octokit+core@3.5.1:
resolution: {integrity: sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw==} resolution: {integrity: sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw==}
@ -4074,7 +4073,6 @@ packages:
'@octokit/types': 6.34.0 '@octokit/types': 6.34.0
deprecation: 2.3.1 deprecation: 2.3.1
once: 1.4.0 once: 1.4.0
dev: true
/@octokit/request/5.6.3: /@octokit/request/5.6.3:
resolution: {integrity: sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==} resolution: {integrity: sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==}
@ -4087,7 +4085,6 @@ packages:
universal-user-agent: 6.0.0 universal-user-agent: 6.0.0
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
dev: true
/@octokit/rest/18.12.0: /@octokit/rest/18.12.0:
resolution: {integrity: sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q==} resolution: {integrity: sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q==}
@ -4104,7 +4101,6 @@ packages:
resolution: {integrity: sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==} resolution: {integrity: sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==}
dependencies: dependencies:
'@octokit/openapi-types': 11.2.0 '@octokit/openapi-types': 11.2.0
dev: true
/@parcel/watcher/2.0.4: /@parcel/watcher/2.0.4:
resolution: {integrity: sha512-cTDi+FUDBIUOBKEtj+nhiJ71AZVlkAsQFuGQTun5tV9mwQBQgZvhCzG+URPQc8myeN32yRVZEfVAPCs1RW+Jvg==} resolution: {integrity: sha512-cTDi+FUDBIUOBKEtj+nhiJ71AZVlkAsQFuGQTun5tV9mwQBQgZvhCzG+URPQc8myeN32yRVZEfVAPCs1RW+Jvg==}
@ -8821,7 +8817,6 @@ packages:
/deprecation/2.3.1: /deprecation/2.3.1:
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
dev: true
/des.js/1.0.1: /des.js/1.0.1:
resolution: {integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==} resolution: {integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==}
@ -9128,6 +9123,7 @@ packages:
/encoding/0.1.13: /encoding/0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
requiresBuild: true
dependencies: dependencies:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
@ -12779,7 +12775,6 @@ packages:
/is-plain-object/5.0.0: /is-plain-object/5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true
/is-potential-custom-element-name/1.0.1: /is-potential-custom-element-name/1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@ -16133,7 +16128,6 @@ packages:
optional: true optional: true
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
dev: true
/node-forge/0.10.0: /node-forge/0.10.0:
resolution: {integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==} resolution: {integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==}
@ -21134,7 +21128,6 @@ packages:
/universal-user-agent/6.0.0: /universal-user-agent/6.0.0:
resolution: {integrity: sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==} resolution: {integrity: sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==}
dev: true
/universalify/0.1.2: /universalify/0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}

View File

@ -19,7 +19,8 @@
"dependencies": { "dependencies": {
"@oclif/core": "^1", "@oclif/core": "^1",
"@oclif/plugin-help": "^5", "@oclif/plugin-help": "^5",
"@oclif/plugin-plugins": "^2.0.1" "@oclif/plugin-plugins": "^2.0.1",
"@octokit/graphql": "4.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^16.9.4", "@types/node": "^16.9.4",
@ -43,6 +44,9 @@
"topics": { "topics": {
"merge": { "merge": {
"description": "Merges other repositories into the monorepo." "description": "Merges other repositories into the monorepo."
},
"transfer-issues": {
"description": "Transfers issues from other repositories into the monorepo."
} }
} }
}, },

View File

@ -95,7 +95,7 @@ export default class Merge extends Command {
*/ */
private async validateArgs( source: string, destination: string ): Promise< void > { private async validateArgs( source: string, destination: string ): Promise< void > {
// We only support pulling from GitHub so the format needs to match that. // We only support pulling from GitHub so the format needs to match that.
if ( ! source.match( /^[a-z0-9\-]+\/[a-z0-9\-]+$/ ) ) { if ( ! source.match( /^[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+$/ ) ) {
this.error( this.error(
'The "source" argument must be in "organization/repository" format' 'The "source" argument must be in "organization/repository" format'
); );

View File

@ -0,0 +1,218 @@
/**
* External dependencies
*/
import { CliUx, Command, Flags } from '@oclif/core';
import { graphql, GraphqlResponseError } from '@octokit/graphql';
/**
* Describes the information for a user that the command needs to operate.
*/
interface APIUser {
id: string;
monorepoID: string;
token: string;
}
/**
* Describes the results from an issue lookup.
*/
interface IssueResults {
totalIssues: number,
cursor: string,
issues: { id: string, title: string }[];
}
export default class TransferIssues extends Command {
static description = 'Transfers issues from another repository into the monorepo.';
static args = [
{
name: 'source',
description: 'The GitHub repository we are transferring from.',
required: true,
}
];
static flags = {
filter: Flags.string(
{
description: 'A search filter to apply when searching for issues to transfer.',
default: 'is:open'
}
)
};
/**
* This method is called to execute the command.
*/
async run(): Promise< void > {
const { args, flags } = await this.parse( TransferIssues );
this.validateArgs( args.source );
const apiUser = await this.getAPIUser();
let confirmation = await CliUx.ux.confirm('Are you sure you want to transfer issues from ' + args.source + ' into the monorepo? (y/n)' );
if (!confirmation) {
this.exit( 0 );
}
confirmation = await CliUx.ux.confirm('This command CANNOT reverse the transfer, are you sure? (y/n)' );
if (!confirmation) {
this.exit( 0 );
}
// Iterate over all of the issues and transfer them to the monorepo.
let cursor: string | null = null;
let totalTransferred = 0;
let totalIssues = null;
do {
const issues: IssueResults = await this.loadIssues( apiUser, args.source, flags.filter, cursor );
if (issues.issues.length === 0) {
break;
}
if (totalIssues === null) {
totalIssues = issues.totalIssues;
confirmation = await CliUx.ux.confirm('This will transfer ' + totalIssues + ' issues, are you sure? (y/n)' );
if (!confirmation) {
this.exit( 0 );
}
}
totalTransferred += await this.transferIssues(apiUser, issues);
cursor = issues.cursor;
} while (cursor !== null) {}
this.log( 'Successfully transferred ' + totalTransferred + '/' + totalIssues + ' issues.' );
}
/**
* Validates all of the arguments to make sure they're compatible with the command.
*
* @param {string} source The GitHub repository we are transferring from.
*/
private validateArgs( source: string ): void {
// We only support pulling from GitHub so the format needs to match that.
if ( ! source.match( /^[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+$/ ) ) {
this.error(
'The "source" argument must be in "organization/repository" format'
);
}
}
/**
* Requests an API token from the user, validates it, and returns information about them if successful.
*/
private async getAPIUser(): Promise< APIUser > {
// Prompt them for a token, rather than storing one. This reduces the likelihood that the command can be accidentally executed.
const token: string = await CliUx.ux.prompt( 'Please supply a GitHub API token', { type: 'hide', required: true });
if (token === '') {
this.error( 'You must enter a valid GitHub API token' );
}
try {
const { viewer } = await graphql(
'{ viewer { id } }',
{
headers: {
authorization: 'token ' + token
}
}
);
const { repository } = await graphql(
'{ repository (owner: "woocommerce", name: "woocommerce" ) { id } }',
{
headers: {
authorization: 'token ' + token
}
}
);
return {
id: viewer.id,
monorepoID: repository.id,
token
};
} catch ( err: any ) {
if ( err?.status == 401 ) {
this.error( 'The given token is invalid' );
}
throw err;
}
}
/**
* Loads a set of issues from the
*
* @param {APIUser} apiUser The API user that is making the transfer request.
* @param {string} source The GitHub repository we are transferring issues from.
* @param {string} filter The search filter for the issue search.
* @param {string|null} cursor The cursor for the current in-progress issue search.
*/
private async loadIssues( apiUser: APIUser, source: string, filter: string, cursor: string | null ): Promise< IssueResults > {
const cursorString = cursor ? ', after: "' + cursor + '"' : '';
const { search } = await graphql(
`
{
search(type: ISSUE, query: "repo:${source} is:issue ${filter}", first: 50${cursorString}) {
nodes {
... on Issue {
id,
title
}
},
issueCount,
pageInfo {
endCursor
}
}
}
`,
{
headers: {
authorization: 'token ' + apiUser.token
}
}
);
const nextCursor = search.pageInfo.endCursor;
const issues: { id: string, title: string }[] = [];
for ( const issue of search.nodes ) {
issues.push({
id: issue.id,
title: issue.title
});
}
return {
totalIssues: search.issueCount,
cursor: nextCursor,
issues: issues
};
}
/**
* Transfers a set of issues to the monorepo.
*
* @param {APIUser} apiUser The API user making the transfer request.
* @param {IssueResult} issues The issues to be transferred to the monorepo.
*/
private async transferIssues( apiUser: APIUser, issues: IssueResults ): Promise< number > {
// Track the number of issues so that we can keep up with them.
let issuesTransferred = 0;
for (const issue of issues.issues) {
CliUx.ux.action.start( 'Transferring "' + issue.title + '"' );
issuesTransferred++;
CliUx.ux.action.stop();
}
return issuesTransferred;
}
}