diff --git a/.prettierrc.js b/.prettierrc.js index 658367d8..b78568fd 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,4 +2,4 @@ module.exports = { singleQuote: true, trailingComma: 'es5', printWidth: 100, -}; +}; \ No newline at end of file diff --git a/README.md b/README.md index 268ec298..c9e6acfe 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ hooks: post_checkout: npm install apply: mv .eslintrc .eslintrc.json pr_message: echo 'Hey! This PR renames `.eslintrc` to `.eslintrc.json`' +issues: + title: "this is my first updated issue" + description: "this is my first updated issue" + labels: ['ENHANCEMENT', 'BUG'] + state: closed + state_reason: completed ``` ### Fields @@ -127,6 +133,16 @@ Hooks define the core functionality of a migration in Shepherd. - **Description**: Commands to generate a pull request message. - **Output**: Anything written to `stdout` is used for the message. Multiple commands will have their outputs concatenated. +- `issue`: + - **Description**: Command to create, update, or close issues. + - **Output**: Depending on the details provided in migration scripts, the issues will be created, updated or closed. + +- `list-issues`: + - **Description**: Commands to list all issues associated with a migration. + - **Output**: All the posted issues are listed in the table format. + + + ### Requirements - Optional: `should_migrate`, `post_checkout` @@ -167,6 +183,8 @@ There are a number of commands that must be run to execute a migration: - `pr-preview`: Prints the commit message that would be used for each repository without actually creating a PR; uses the `pr_message` hook. - `pr`: Creates a PR for each repo with the message generated from the `pr_message` hook. - `version`: Prints Shepherd version +- `issue`: Create, update, or close issues across multiple repository. +- `list-issues`: List all issues associated with a migration. By default, `checkout` will use the adapter to figure out which repositories to check out, and the remaining commands will operate on all checked-out repos. To only checkout a specific repo or to operate on only a subset of the checked-out repos, you can use the `--repos` flag, which specifies a comma-separated list of repos: diff --git a/package-lock.json b/package-lock.json index fd499f34..a653ccba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "netrc": "^0.1.4", "ora": "^5.4.1", "preferences": "^2.0.2", - "simple-git": "^3.22.0" + "simple-git": "^3.22.0", + "table": "^6.8.1" }, "bin": { "shepherd": "lib/cli.js" @@ -2948,6 +2949,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3922,8 +3931,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/emojilib": { "version": "2.4.0", @@ -4704,8 +4712,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -5681,7 +5688,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -6819,6 +6825,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==" + }, "node_modules/lodash.uniqby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", @@ -10559,7 +10570,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -10760,6 +10770,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -11495,6 +11513,22 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11659,7 +11693,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -11791,6 +11824,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/table": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", + "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -12332,7 +12400,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 853979f7..a05d9e94 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/js-yaml": "^4.0.9", "chalk": "^4.1.2", "child-process-promise": "^2.2.1", + "table": "^6.8.1", "commander": "^11.1.0", "fs-extra": "^11.2.0", "joi": "^17.11.0", diff --git a/src/adapters/adapter.mock.ts b/src/adapters/adapter.mock.ts index 4d5c1f8d..ad3b0fe4 100644 --- a/src/adapters/adapter.mock.ts +++ b/src/adapters/adapter.mock.ts @@ -21,6 +21,8 @@ const mockAdapter: IRepoAdapter = { getDataDir: jest.fn() as unknown as (repo: IRepo) => string, getBaseBranch: jest.fn() as unknown as (repo: IRepo) => string, getEnvironmentVariables: jest.fn() as unknown as (repo: IRepo) => Promise, + createIssue: jest.fn() as unknown as (repo: IRepo) => Promise, + updateIssue: jest.fn() as unknown as (repo: IRepo) => Promise, }; export default mockAdapter; diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 73d82e8e..94eda942 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -2,6 +2,10 @@ export interface IRepo { [key: string]: any; } +export interface IssueTracker { + [key: string]: any; +} + export interface IEnvironmentVariables { [key: string]: string; } @@ -40,6 +44,10 @@ interface IRepoAdapter { getBaseBranch(repo: IRepo): string; getEnvironmentVariables(repo: IRepo): Promise; + + createIssue(repo: IRepo): Promise; + + updateIssue(repo: IRepo, issueNumber: number): Promise; } export default IRepoAdapter; diff --git a/src/adapters/git.ts b/src/adapters/git.ts index 0ad0c8ff..83f659cc 100644 --- a/src/adapters/git.ts +++ b/src/adapters/git.ts @@ -94,6 +94,10 @@ abstract class GitAdapter implements IRepoAdapter { protected abstract getRepositoryUrl(repo: IRepo): string; + public abstract createIssue(repo: IRepo): Promise; + + public abstract updateIssue(repo: IRepo, issueNumber: number): Promise; + protected git(repo: IRepo): any { return simpleGit(this.getRepoDir(repo)); } diff --git a/src/adapters/github.ts b/src/adapters/github.ts index d218843d..2f62b0a5 100644 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -312,6 +312,43 @@ class GithubAdapter extends GitAdapter { return `git@${gitHubEnterpriseBaseUrl}:${repo.owner}/${repo.name}.git`; } + public createIssue = async (repo: IRepo): Promise => { + const { + migration: { spec }, + } = this.migrationContext; + const { owner, name } = repo; + const { issues } = spec; + //Create an issue with the title , issue message and labels + + return await this.githubService.createAndGetIssueNumber({ + owner, + repo: name, + title: issues?.title || 0, + body: issues?.description, + labels: issues?.labels, + }); + }; + + public updateIssue = async (repo: IRepo, issueNumber: number): Promise => { + const { + migration: { spec }, + } = this.migrationContext; + const { owner, name } = repo; + const { issues } = spec; + + //Update an issue's title , issue message and labels + await this.githubService.updateIssue({ + owner, + repo: name, + title: issues?.title?.toString(), + issue_number: issueNumber, + body: issues?.description, + labels: issues?.labels, + state: issues?.state, + state_reason: issues?.state_reason, + }); + }; + private async checkActionSafety(repo: IRepo): Promise { const { owner, name } = repo; diff --git a/src/cli.ts b/src/cli.ts index 3be91993..d688b814 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,8 @@ import prStatus from './commands/pr-status'; import push from './commands/push'; import reset from './commands/reset'; import version from './commands/version'; - +import issue from './commands/issue'; +import listIssues from './commands/list-issues'; import ConsoleLogger from './logger'; const program = new Command(); @@ -152,6 +153,10 @@ addCommand('pr-status', 'Check the status of all PRs for the specified migration // These commands don't take --repos arguments addCommand('list', 'List all checked out repositories for the given migration', false, list); +addCommand('issue', 'open an issue for the specified repos', true, issue); + +addCommand('list-issues', 'List all opened issues using migration', true, listIssues); + program .command('version') .description('Print Shepherd version') diff --git a/src/commands/issue.test.ts b/src/commands/issue.test.ts new file mode 100644 index 00000000..98e7c410 --- /dev/null +++ b/src/commands/issue.test.ts @@ -0,0 +1,83 @@ +import issue from './issue'; +import { IMigrationContext } from '../migration-context'; +import mockAdapter from '../adapters/adapter.mock'; +import mockLogger from '../logger/logger.mock'; +import { getIssueListsFromTracker } from '../util/persisted-data'; + +jest.mock('../util/persisted-data'); + +describe('issue command', () => { + let mockContext: IMigrationContext; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext = { + shepherd: { + workingDirectory: 'workingDirectory', + }, + migration: { + migrationDirectory: 'migrationDirectory', + spec: { + id: 'id', + title: 'title', + adapter: { + type: 'adapter', + }, + hooks: {}, + issues: { + title: 'issue title', + description: 'issue description', + state: 'open', + state_reason: 'not_planned', + labels: ['bug'] + } + }, + workingDirectory: 'workingDirectory', + selectedRepos: [{ name: 'selectedRepos' }], + repos: [{ name: 'selectedRepos' }], + upstreamOwner: 'upstreamOwner', + }, + adapter: mockAdapter, + logger: mockLogger, + }; + }); + + it('create issue if the issue doesnt exists in tracker', + async () => { + (getIssueListsFromTracker as jest.Mock).mockResolvedValueOnce( + [{ + issueNumber: '7', + title: 'this is my first updated issue', + owner: 'newowner', + repo: 'newrepo' + }] + ); + + await issue(mockContext); + + expect(mockContext.adapter.createIssue).toHaveBeenCalled(); + }); + + it('update issue if the issue exists in tracker', + async () => { + (getIssueListsFromTracker as jest.Mock).mockResolvedValueOnce( + [{ + issueNumber: '7', + title: 'this is my first updated issue', + owner: 'upstreamOwner', + repo: 'selectedRepos' + }] + ); + + await issue(mockContext); + + expect(mockContext.adapter.updateIssue).toHaveBeenCalled(); + }); + + it('should catch error when issue tracker is accessed', async () => { + (getIssueListsFromTracker as jest.Mock).mockRejectedValueOnce([]); + await issue(mockContext); + expect(mockLogger.error).toHaveBeenCalledWith("Error to post/update issue"); + }); + +}); diff --git a/src/commands/issue.ts b/src/commands/issue.ts new file mode 100644 index 00000000..ab381834 --- /dev/null +++ b/src/commands/issue.ts @@ -0,0 +1,48 @@ +import { IMigrationContext } from '../migration-context'; +import forEachRepo from '../util/for-each-repo'; +import { IssueTracker } from '../adapters/base'; +import { getIssueListsFromTracker, updatePostedIssuesLists } from '../util/persisted-data'; + +export default async (context: IMigrationContext) => { + const { + adapter, + logger, + migration: { spec }, + } = context; + try { + const issuesList: IssueTracker[] = await getIssueListsFromTracker(context); + logger.spinner('Fetched the issue lists'); + + await forEachRepo(context, async (repo) => { + logger.spinner('Posting an issue'); + + const issueNumber = issuesList + .find((issue) => issue.repo === repo.name) + ?.issueNumber?.toString(); + + if (issueNumber) { + await adapter.updateIssue(repo, issueNumber); + issuesList + .filter((issueFromTracker) => issueFromTracker.issueNumber === issueNumber) + .map((specificIssue) => (specificIssue.title = spec?.issues?.title)); + logger.spinner(`Issue updated issueNumber# ${issueNumber} for repo ${repo.name}`); + } else { + const issueNumber: any = await adapter.createIssue(repo); + issuesList.push({ + issueNumber, + title: spec?.issues?.title, + owner: repo.owner, + repo: repo.name, + }); + logger.spinner('Issue created'); + } + }); + //Add the opened issues with issue_number and repo in the issue_tracker.json + if (issuesList.length > 0) { + await updatePostedIssuesLists(context, issuesList); + } + } catch (e: any) { + logger.error("Error to post/update issue"); + logger.spinner('Failed to post/update an issue'); + } +}; diff --git a/src/commands/list-issues.test.ts b/src/commands/list-issues.test.ts new file mode 100644 index 00000000..779e6128 --- /dev/null +++ b/src/commands/list-issues.test.ts @@ -0,0 +1,70 @@ +import listIssues from './list-issues'; +import { IMigrationContext } from '../migration-context'; +import mockAdapter from '../adapters/adapter.mock'; +import mockLogger from '../logger/logger.mock'; +import { getIssueTrackerFile } from '../util/persisted-data'; + +jest.mock('fs-extra', () => { + return { + // Mock other methods as needed + readFile: jest.fn().mockResolvedValue('{"name": "test"}'), + }; +}); + +jest.mock('../util/persisted-data'); + +describe('list-issue command', () => { + let mockContext: IMigrationContext; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext = { + shepherd: { + workingDirectory: 'workingDirectory', + }, + migration: { + migrationDirectory: 'migrationDirectory', + spec: { + id: 'id', + title: 'title', + adapter: { + type: 'adapter', + }, + hooks: {}, + issues: { + title: 'issue title', + description: 'issue description', + state: 'open', + state_reason: 'not_planned', + labels: ['bug'] + } + }, + workingDirectory: 'workingDirectory', + selectedRepos: [{ name: 'selectedRepos' }], + repos: [{ name: 'selectedRepos' }], + upstreamOwner: 'upstreamOwner', + }, + adapter: mockAdapter, + logger: mockLogger, + }; + }); + + it('should list issues when the command is invoked', + async () => { + + (getIssueTrackerFile as jest.Mock).mockResolvedValueOnce( + [{ + issueNumber: '7', + title: 'this is my first updated issue', + owner: 'newOwner', + repo: 'newRepo' + }] + ); + + await listIssues(mockContext); + + expect(process.stdout.write).toEqual(expect.any(Function)) + }); + +}); diff --git a/src/commands/list-issues.ts b/src/commands/list-issues.ts new file mode 100644 index 00000000..bfcac8a2 --- /dev/null +++ b/src/commands/list-issues.ts @@ -0,0 +1,20 @@ +import { IMigrationContext } from '../migration-context'; +import { getIssueTrackerFile } from '../util/persisted-data'; +import fs from 'fs-extra'; +import { table } from 'table'; + +export default async (context: IMigrationContext) => { + + const rows: any = []; + + const columns = ['issue Number', 'issue Title', 'Owner', 'Repo Name']; + + const issuesList = JSON.parse(await fs.readFile(getIssueTrackerFile(context), 'utf8')); + + for (let i = 0; i < issuesList.length; i++){ + const issue: any = issuesList[i]; + rows.push([issue.issueNumber, issue.title, issue.owner, issue.repo]); + } + + process.stdout.write(table([columns, ...rows])); +}; diff --git a/src/migration-context.ts b/src/migration-context.ts index e6680f6a..953cb01b 100644 --- a/src/migration-context.ts +++ b/src/migration-context.ts @@ -21,3 +21,15 @@ export interface IMigrationContext { adapter: IRepoAdapter; logger: ILogger; } + +export enum state { + open, + closed, +} + +export enum state_reason { + completed, + not_planned, + reopened, + null, +} diff --git a/src/services/github.ts b/src/services/github.ts index 507e7807..1bda8bc9 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -26,6 +26,9 @@ export default class GithubService { if (octokit) { this.octokit = octokit; } else { + // need a path to the .netrc file from home directory + + // const netrcFilePath = path.join(os.homedir(), '.netrc'); const netrcAuth = netrc(); const token = process.env.GITHUB_TOKEN || _.get(netrcAuth['api.github.com'], 'password', undefined); @@ -137,6 +140,32 @@ export default class GithubService { return this.octokit.repos.getBranch(criteria); } + public createIssue( + criteria: RestEndpointMethodTypes['issues']['create']['parameters'] + ): Promise { + return this.octokit.issues.create(criteria); + } + + public updateIssue(criteria: { + owner: any; + issue_number: number; + repo: any; + title: any; + body: any; + labels: any; + state: any; + state_reason: any; + }): Promise { + return this.octokit.issues.update(criteria); + } + + public async createAndGetIssueNumber( + criteria: RestEndpointMethodTypes['issues']['create']['parameters'] + ): Promise { + const { data } = await this.createIssue(criteria); + return data.number.toString(); + } + public async getActiveReposForSearchTypeAndQuery({ search_type, search_query, diff --git a/src/util/migration-spec.ts b/src/util/migration-spec.ts index d9d28206..d10a1464 100644 --- a/src/util/migration-spec.ts +++ b/src/util/migration-spec.ts @@ -12,6 +12,18 @@ export interface IMigrationHooks { [name: string]: string[] | undefined; } +export interface IMigrationIssues { + title: string | number; + description?: string; + labels?: string[]; + state?: string; + state_reason?: string; +} + +const state = ['open', 'closed']; + +const state_reason = ['completed', 'not_planned', 'reopened']; + export type MigrationPhase = [keyof IMigrationHooks]; export interface IMigrationSpec { @@ -22,6 +34,7 @@ export interface IMigrationSpec { [key: string]: any; }; hooks: IMigrationHooks; + issues?: IMigrationIssues; } export function loadSpec(directory: string): IMigrationSpec { @@ -69,6 +82,17 @@ export function validateSpec(spec: any) { apply: hookSchema, pr_message: hookSchema, }), + issues: Joi.object({ + title: Joi.any().required(), + description: Joi.string().required(), + state: Joi.string() + .valid(...state) + .optional(), + state_reason: Joi.string() + .valid(...state_reason) + .optional(), + labels: hookSchema.optional(), + }), }); return schema.validate(spec); diff --git a/src/util/persisted-data.ts b/src/util/persisted-data.ts index 98474c29..d887636a 100644 --- a/src/util/persisted-data.ts +++ b/src/util/persisted-data.ts @@ -3,7 +3,7 @@ import yaml from 'js-yaml'; import { differenceWith, unionWith } from 'lodash'; import path from 'path'; -import { IRepo } from '../adapters/base'; +import { IRepo, IssueTracker } from '../adapters/base'; import { IMigrationContext } from '../migration-context'; const jsonStringify = (data: any) => JSON.stringify(data, undefined, 2); @@ -26,6 +26,19 @@ const getRepoListFile = (migrationContext: IMigrationContext) => { return path.join(migrationContext.migration.workingDirectory, 'repos.json'); }; +const getIssueTrackerFile = (migrationContext: IMigrationContext) => { + return path.join(migrationContext.migration.workingDirectory, 'issue_tracker.json'); +}; + +const getIssueListsFromTracker = async (migrationContext: IMigrationContext) => { + const issueTrackerFile = getIssueTrackerFile(migrationContext); + + if (await fs.pathExists(issueTrackerFile)) { + return JSON.parse(await fs.readFile(issueTrackerFile, 'utf8')); + } + return []; +}; + const getLegacyRepoListFile = (migrationContext: IMigrationContext) => { return path.join(migrationContext.migration.workingDirectory, 'repos.yml'); }; @@ -66,4 +79,35 @@ const updateRepoList = async ( return repos; }; -export { updateRepoList, loadRepoList }; +const updatePostedIssuesLists = async ( + migrationContext: IMigrationContext, + issuePostedRepos: IssueTracker[] +): Promise => { + const issueTrackerFile = getIssueTrackerFile(migrationContext); + //write all posted issues with issue number in the issue_tracker.json + return await fs.outputFile(issueTrackerFile, JSON.stringify(issuePostedRepos)); +}; + +const getIssueNumberForRepo = async ( + migrationContext: IMigrationContext, + repo: string +): Promise => { + const issueTrackerFile = getIssueTrackerFile(migrationContext); + if (!(await fs.pathExists(issueTrackerFile))) { + return null; + } + const issueTracker: IssueTracker[] = JSON.parse(await fs.readFile(issueTrackerFile, 'utf8')); + // read the issue number from the issue tracker for each repo + return issueTracker + .filter((issue) => issue.repo === repo) + .map((specificRepo) => specificRepo.issueNumber); +}; + +export { + updateRepoList, + loadRepoList, + updatePostedIssuesLists, + getIssueTrackerFile, + getIssueListsFromTracker, + getIssueNumberForRepo, +};