├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── build-test-and-lint.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── src ├── index.ts ├── logger.ts ├── types.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build-test-and-lint.yml: -------------------------------------------------------------------------------- 1 | name: Build, test and lint 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4.3.0 14 | with: 15 | node-version: 20.x 16 | cache: "npm" 17 | - run: npm ci 18 | - run: npm run lint 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "39 1 * * 3" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["javascript"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4.3.0 17 | with: 18 | node-version: 20.x 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | run: npx semantic-release 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, // Specify if you want to print semicolons at the end of statements 3 | "singleQuote": true, // If you want to use single quotes 4 | "arrowParens": "avoid", // Include parenthesis around a sole arrow function parameter 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Development 4 | 5 | To make changes, edit the TypeScript files in `src/`. 6 | 7 | While you're making changes locally, you can test your changes without compiling manually each time using `npm run dev` - for example `npm run dev -- --organization acme-corp`. 8 | 9 | ## Linting 10 | 11 | Code style and formatting are enforced using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). 12 | 13 | You can lint your code by running `npm run lint`, and automatically fix many issues by running `npm run lint-and-fix`. 14 | 15 | Code is automatically linted on push, and PRs will not be able to merge merged until all checks pass. 16 | 17 | ## Releasing a new version 18 | 19 | This project uses [`semantic-release`](https://github.com/semantic-release/semantic-release) to automatically cut a release and push the update to [npm](https://npmjs.com) whenever changes are merged to `main`. 20 | 21 | As part of the release process, the TypeScirpt will be compiled. 22 | 23 | Commit messages starting with `feat:` will trigger a minor version, and `fix:` will trigger a patch version. If the commit message contains `BREAKING CHANGE:`, a major version will be released. 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Tim Rogers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Migration Monitor 2 | 3 | This command line tool allows you to monitor an organization's [GitHub Enterprise Importer (GEI)](https://docs.github.com/en/migrations/using-github-enterprise-importer) migrations. 4 | 5 | It'll watch your organization's migrations and display a UI with your queued, in progress, successful and failed migrations, plus an event log. 6 | 7 | ## Usage 8 | 9 | 1. Make sure you have [Node.js](https://nodejs.org/) installed. You can double check by running `node --version`. 10 | 2. Make sure you have [npm](https://npmjs.com) installed. You can double check by running `npm --version`. 11 | 3. Set the `GITHUB_TOKEN` environment variable to a classic personal access token (PAT) with the `admin:org` scope. 12 | 4. Run `npx github-migration-monitor --organization ORGANIZATION`, replacing `ORGANIZATION` with your organization name. 13 | 14 | ### Customizing how often the CLI polls for updates 15 | 16 | By default, the CLI will poll for updates to your migrations every 10 seconds. 17 | 18 | You can customize this by setting the `--interval-in-seconds` argument with a value in seconds. 19 | 20 | ### Setting the GitHub token using a command line argument 21 | 22 | Instead of specifying your access token using the `GITHUB_TOKEN` environment variable, you can alternatively use the `--github-token` argument 23 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tsParser from "@typescript-eslint/parser"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [{ 17 | ignores: ["dist/**/*"], 18 | }, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"), { 19 | languageOptions: { 20 | globals: { 21 | ...globals.node, 22 | }, 23 | 24 | parser: tsParser, 25 | ecmaVersion: "latest", 26 | sourceType: "module", 27 | }, 28 | }]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-migration-monitor", 3 | "type": "module", 4 | "description": "Monitors GitHub Enterprise Importer (GEI) migrations for an organization", 5 | "version": "0.0.0-development", 6 | "scripts": { 7 | "dev": "npx ts-node --esm src/index.ts", 8 | "build": "npx tsc", 9 | "prepublish": "npm run build", 10 | "lint": "eslint .", 11 | "lint-and-fix": "eslint . --fix", 12 | "semantic-release": "semantic-release" 13 | }, 14 | "module": "./dist/index.js", 15 | "homepage": "https://github.com/timrogers/github-migration-monitor", 16 | "files": [ 17 | "dist" 18 | ], 19 | "bin": "./dist/index.js", 20 | "author": "Tim Rogers ", 21 | "license": "MIT", 22 | "dependencies": { 23 | "@octokit/graphql": "^8.0.1", 24 | "@octokit/plugin-paginate-graphql": "^5.1.0", 25 | "blessed": "^0.1.81", 26 | "blessed-contrib": "^4.11.0", 27 | "commander": "^13.1.0", 28 | "cross-fetch": "^4.0.0", 29 | "javascript-time-ago": "^2.5.9", 30 | "lodash.groupby": "^4.6.0", 31 | "octokit": "^4.0.2", 32 | "winston": "^3.8.2" 33 | }, 34 | "devDependencies": { 35 | "@eslint/eslintrc": "^3.2.0", 36 | "@eslint/js": "^9.17.0", 37 | "@tsconfig/node18": "^18.2.0", 38 | "@types/blessed": "^0.1.21", 39 | "@types/lodash.groupby": "^4.6.7", 40 | "@types/node": "^22.7.4", 41 | "@typescript-eslint/eslint-plugin": "^8.18.1", 42 | "@typescript-eslint/parser": "^8.18.1", 43 | "eslint": "^9.17.0", 44 | "eslint-config-prettier": "^9.0.0", 45 | "globals": "^16.0.0", 46 | "prettier": "^3.1.0", 47 | "semantic-release": "^24.0.0", 48 | "ts-node": "^10.9.1", 49 | "typescript": "^5.0.4" 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "https://github.com/timrogers/github-migration-monitor.git" 54 | }, 55 | "release": { 56 | "branches": [ 57 | "main" 58 | ] 59 | } 60 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { program } from 'commander'; 4 | import { Octokit } from "@octokit/core"; 5 | import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; 6 | import groupBy from 'lodash.groupby'; 7 | import fetch from 'cross-fetch'; 8 | import blessed from 'blessed'; 9 | import contrib from 'blessed-contrib'; 10 | import TimeAgo from 'javascript-time-ago' 11 | import en from 'javascript-time-ago/locale/en' 12 | 13 | TimeAgo.addDefaultLocale(en); 14 | const timeAgo = new TimeAgo('en'); 15 | 16 | import logger from './logger.js'; 17 | import { MigrationState, Opts, RepositoryMigration } from './types.js'; 18 | import { getRequestIdFromError, parseSince, presentState, serializeError } from './utils.js'; 19 | 20 | program 21 | .name('github-migration-monitor') 22 | .description('Monitors GitHub Enterprise Importer (GEI) migrations for an organization') 23 | .option('--github-token ', 'A GitHub personal access token (PAT) with `read:org` scope. Required to be set using this option or the `GITHUB_TOKEN` environment variable.', process.env.GITHUB_TOKEN) 24 | .requiredOption('--organization ', 'The GitHub organization to monitor') 25 | .option('--interval-in-seconds ', 'Interval in seconds between refreshes', (value) => parseInt(value), 10) 26 | .option('--since ', 'Only show migrations created after this date and/or time. If this argument isn\'t set, migrations from the last 7 days will be shown. Supports ISO 8601 dates (e.g. `2023-05-18`) - which are interpreted as 00:00:00 in your machine\'s local time - and ISO 8601 timestamps.'); 27 | 28 | program.parse(); 29 | 30 | const opts: Opts = program.opts(); 31 | 32 | const { 33 | githubToken, 34 | intervalInSeconds, 35 | organization, 36 | } = opts; 37 | 38 | if (!githubToken) { 39 | logger.error('GitHub token is required. Please set it using the `--github-token` option or the `GITHUB_TOKEN` environment variable.'); 40 | process.exit(1); 41 | } 42 | 43 | let since: Date | undefined = undefined; 44 | 45 | if (opts.since) { 46 | try { 47 | since = parseSince(opts.since); 48 | } catch (e) { 49 | logger.error(e); 50 | process.exit(1); 51 | } 52 | } 53 | 54 | const intervalInMilliseconds = intervalInSeconds * 1_000; 55 | 56 | const OctokitWithPaginateGraphql = Octokit.plugin(paginateGraphQL); 57 | 58 | const octokit = new OctokitWithPaginateGraphql({ auth: githubToken }); 59 | 60 | const getRepositoryMigrations = async (organizationId: string, since: Date | undefined): Promise => { 61 | // `@octokit/plugin-paginate-graphql` doesn't support GraphQL variables yet, so we have to use string interpolation 62 | // to add the ID to the query 63 | const paginatedResponse = await octokit.graphql.paginate( 64 | ` 65 | query paginateRepositoryMigrations($cursor: String) { 66 | node(id: ${JSON.stringify(organizationId)}) { 67 | ... on Organization { 68 | repositoryMigrations(after: $cursor, first: 100) { 69 | nodes { 70 | id 71 | createdAt 72 | failureReason 73 | repositoryName 74 | state 75 | migrationLogUrl 76 | } 77 | pageInfo { 78 | hasNextPage 79 | endCursor 80 | } 81 | } 82 | } 83 | } 84 | } ` 85 | ); 86 | 87 | const repositoryMigrations = paginatedResponse.node.repositoryMigrations.nodes as RepositoryMigration[]; 88 | 89 | if (since) { 90 | return repositoryMigrations.filter(migration => new Date(migration.createdAt) >= since); 91 | } else { 92 | return repositoryMigrations; 93 | } 94 | }; 95 | 96 | const getOrganizationId = async (organizationLogin: string): Promise => { 97 | const response = await octokit.graphql(` 98 | query getOrganizationId($organizationLogin: String!) { 99 | organization(login: $organizationLogin) { 100 | id 101 | } 102 | } 103 | `, { 104 | organizationLogin 105 | }) as { organization: { id: string }}; 106 | 107 | return response.organization.id; 108 | } 109 | 110 | const fetchMigrationLogUrl = async (migrationId: string): Promise => { 111 | const response = await octokit.graphql(` 112 | query getMigrationLogUrl($migrationId: ID!) { 113 | node(id: $migrationId) { 114 | ... on Migration { migrationLogUrl } 115 | } 116 | } 117 | `, { 118 | migrationId 119 | }) as { node: { migrationLogUrl: string | null }}; 120 | 121 | return response.node.migrationLogUrl; 122 | } 123 | 124 | const MAXIMUM_ATTEMPTS_TO_GET_MIGRATION_LOG = 5; 125 | 126 | const getMigrationLogEntries = async (migration: RepositoryMigration, currentAttempt = 1): Promise => { 127 | try { 128 | const migrationLogUrl = await fetchMigrationLogUrl(migration.id); 129 | 130 | if (migrationLogUrl) { 131 | const migrationLogResponse = await fetch(migrationLogUrl); 132 | 133 | if (migrationLogResponse.ok) { 134 | const migrationLog = await migrationLogResponse.text(); 135 | return migrationLog.split('\n'); 136 | } else { 137 | throw `Migration log URL found but fetching it returned ${migrationLogResponse.status} ${migrationLogResponse.statusText}`; 138 | } 139 | } else { 140 | throw 'Migration found but migration log URL not yet available'; 141 | } 142 | } catch (e) { 143 | if (currentAttempt < MAXIMUM_ATTEMPTS_TO_GET_MIGRATION_LOG) { 144 | logWarn(`failed to download migration log after ${currentAttempt} attempts (${serializeError(e)}), trying again...`, migration); 145 | return getMigrationLogEntries(migration, currentAttempt + 1); 146 | } else { 147 | logError(`failed to download migration log after ${MAXIMUM_ATTEMPTS_TO_GET_MIGRATION_LOG} attempt(s): ${serializeError(e)}`, migration); 148 | return []; 149 | } 150 | } 151 | } 152 | 153 | const logMigrationWarnings = async (migration: RepositoryMigration): Promise => { 154 | const migrationLogEntries = await getMigrationLogEntries(migration); 155 | 156 | const migrationLogWarnings = migrationLogEntries.filter((entry) => entry.includes('WARN')); 157 | 158 | for (const migrationLogWarning of migrationLogWarnings) { 159 | const presentedWarning = migrationLogWarning.split(' -- ')[1]; 160 | logWarn(`returned a warning: ${presentedWarning}`, migration); 161 | } 162 | } 163 | 164 | const screen = blessed.screen(); 165 | 166 | const grid = new contrib.grid({ rows: 12, cols: 12, screen: screen }); 167 | 168 | const TOP_ROW_COLUMN_WIDTHS = [40, 30]; 169 | 170 | const queuedTable = grid.set(0, 0, 7, 4, contrib.table, { label: 'Queued', keys: true, fg: 'white', selectedFg: 'white', selectedBg: null, interactive: true, border: { type: 'line', fg: 'cyan' }, columnSpacing: 5, columnWidth: TOP_ROW_COLUMN_WIDTHS }) as contrib.Widgets.TableElement; 171 | 172 | const inProgressTable = grid.set(0, 4, 7, 4, contrib.table, { label: 'In Progress', keys: true, fg: 'white', selectedFg: 'white', selectedBg: 'blue', interactive: true, border: { type: 'line', fg: 'yellow' }, columnSpacing: 5, columnWidth: TOP_ROW_COLUMN_WIDTHS }) as contrib.Widgets.TableElement; 173 | 174 | const succeededTable = grid.set(0, 8, 7, 4, contrib.table, { label: 'Succeeded', keys: true, fg: 'white', selectedFg: 'white', selectedBg: 'blue', interactive: true, border: { type: 'line', fg: 'green' }, columnSpacing: 5, columnWidth: TOP_ROW_COLUMN_WIDTHS }) as contrib.Widgets.TableElement; 175 | 176 | const failedTable = grid.set(7, 0, 3, 12, contrib.table, { label: 'Failed', keys: false, fg: 'white', border: { type: 'line', bg: 'red' }, columnSpacing: 5, columnWidth: [50, 20, 100] }) as contrib.Widgets.TableElement; 177 | 178 | const eventLog = grid.set(10, 0, 2, 12, contrib.log, { fg: 'green', selectedFg: 'green', label: 'Event Log' }) as contrib.Widgets.LogElement; 179 | 180 | const UI_ELEMENTS: blessed.Widgets.BoxElement[] = [queuedTable, inProgressTable, succeededTable, failedTable, eventLog]; 181 | 182 | screen.key(['escape', 'q', 'C-c'], () => { 183 | process.exit(0); 184 | }); 185 | 186 | // Fixes https://github.com/yaronn/blessed-contrib/issues/10 187 | screen.on('resize', function () { 188 | for (const element of UI_ELEMENTS) { 189 | element.emit('attach'); 190 | } 191 | }); 192 | 193 | const logError = (message: string, migration?: RepositoryMigration) => eventLog.log(`${buildLogMessagePrefix('ERROR', migration)} ${message}`); 194 | const logInfo = (message: string, migration?: RepositoryMigration) => eventLog.log(`${buildLogMessagePrefix('INFO', migration)} ${message}`); 195 | const logWarn = (message: string, migration?: RepositoryMigration) => eventLog.log(`${buildLogMessagePrefix('WARN', migration)} ${message}`); 196 | 197 | const buildLogMessagePrefix = (level: string, migration: RepositoryMigration | undefined): string => { 198 | if (migration) { 199 | return `[${level}] Migration of ${migration.repositoryName} (${migration.id})`; 200 | } else { 201 | return `[${level}]`; 202 | } 203 | } 204 | 205 | const logFailedMigration = (migration: RepositoryMigration): void => { logError(`failed: ${migration.failureReason}`, migration) }; 206 | const logSuccessfulMigration = (migration: RepositoryMigration): void => { logInfo(`succeeded`, migration) }; 207 | 208 | const repositoryMigrationToTableEntry = (repositoryMigration: RepositoryMigration, startedAt: Date | undefined): string[] => { 209 | if (repositoryMigration.state === 'IN_PROGRESS') { 210 | const duration = startedAt ? `started ${timeAgo.format(startedAt)}` : `queued ${timeAgo.format(new Date(repositoryMigration.createdAt))}`; 211 | return [repositoryMigration.repositoryName, duration]; 212 | } else { 213 | return [repositoryMigration.repositoryName, `queued ${timeAgo.format(new Date(repositoryMigration.createdAt))}`]; 214 | } 215 | }; 216 | 217 | const failedRepositoryMigrationToTableEntry = (repositoryMigration: RepositoryMigration, startedAt: Date | undefined): string[] => repositoryMigrationToTableEntry(repositoryMigration, startedAt).concat(repositoryMigration.failureReason || ''); 218 | 219 | const TABLE_COLUMNS = ['Repository', 'Duration']; 220 | 221 | const updateScreen = (repositoryMigrations: RepositoryMigration[], repositoryMigrationsStartedAt: Map): void => { 222 | const migrationsByState = groupBy(repositoryMigrations, (migration) => migration.state); 223 | 224 | const queuedMigrations = migrationsByState[MigrationState.QUEUED] || []; 225 | const notStartedMigrations = migrationsByState[MigrationState.NOT_STARTED] || []; 226 | const pendingValidationMigrations = migrationsByState[MigrationState.PENDING_VALIDATION] || []; 227 | 228 | const failedMigrations = migrationsByState[MigrationState.FAILED] || []; 229 | const failedValidationMigrations = migrationsByState[MigrationState.FAILED_VALIDATION] || []; 230 | 231 | const queuedMigrationsForTable = queuedMigrations.concat(notStartedMigrations).concat(pendingValidationMigrations); 232 | const inProgressMigrationsForTable = migrationsByState[MigrationState.IN_PROGRESS] || []; 233 | const succeededMigrationsForTable = migrationsByState[MigrationState.SUCCEEDED] || []; 234 | const failedMigrationsForTable = failedMigrations.concat(failedValidationMigrations).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 235 | 236 | queuedTable.setData({ 237 | headers: TABLE_COLUMNS, 238 | data: queuedMigrationsForTable.map(migration => repositoryMigrationToTableEntry(migration, repositoryMigrationsStartedAt.get(migration.id))) 239 | }); 240 | 241 | inProgressTable.setData({ 242 | headers: TABLE_COLUMNS, 243 | data: inProgressMigrationsForTable.map(migration => repositoryMigrationToTableEntry(migration, repositoryMigrationsStartedAt.get(migration.id))) 244 | }); 245 | 246 | succeededTable.setData({ 247 | headers: TABLE_COLUMNS, 248 | data: succeededMigrationsForTable.map(migration => repositoryMigrationToTableEntry(migration, repositoryMigrationsStartedAt.get(migration.id))) 249 | }); 250 | 251 | failedTable.setData({ 252 | headers: ['Destination', 'Duration', 'Error'], 253 | data: failedMigrationsForTable.map(migration => failedRepositoryMigrationToTableEntry(migration, repositoryMigrationsStartedAt.get(migration.id))) 254 | }); 255 | 256 | screen.render(); 257 | }; 258 | 259 | const isMigrationStarted = (migration: RepositoryMigration): boolean => migration.state !== MigrationState.NOT_STARTED && migration.state !== MigrationState.QUEUED; 260 | 261 | const getNoMigrationsFoundMessage = (since: Date | undefined): string => { 262 | if (since) { 263 | return 'No migrations found since ' + since.toISOString(); 264 | } else { 265 | return 'No migrations found'; 266 | } 267 | } 268 | 269 | (async () => { 270 | let repositoryMigrationsState = [] as RepositoryMigration[]; 271 | 272 | // The GitHub API doesn't return when a migration started - only when it was queued (`createdAt`). 273 | // We use this map to keep track of when a migration started so we can show how long it's been running. 274 | const repositoryMigrationStartedAt = new Map(); 275 | 276 | const organizationId = await getOrganizationId(organization); 277 | 278 | async function updateRepositoryMigration(isFirstRun: boolean): Promise { 279 | let latestRepositoryMigrations: RepositoryMigration[] = []; 280 | 281 | const startedUpdatingAt = new Date(); 282 | 283 | try { 284 | logInfo('Loading migrations...'); 285 | latestRepositoryMigrations = await getRepositoryMigrations(organizationId, since); 286 | } catch (e) { 287 | const runtimeInMilliseconds = new Date().getTime() - startedUpdatingAt.getTime(); 288 | const requestId = getRequestIdFromError(e); 289 | logError(`Failed to load migrations after ${runtimeInMilliseconds}ms (${requestId ?? '-'}): "${serializeError(e)}". Trying again in ${opts.intervalInSeconds} second(s)`); 290 | 291 | // If we failed to fetch the migrations and it's currently the first run, we still pretend it's the first run next time 292 | setTimeout(() => updateRepositoryMigration(isFirstRun), intervalInMilliseconds); 293 | return; 294 | } 295 | 296 | logInfo(`Loaded ${latestRepositoryMigrations.length} migration(s) in ${new Date().getTime() - startedUpdatingAt.getTime()}ms`); 297 | 298 | const countsByState = Object.fromEntries( 299 | Object 300 | .entries(groupBy(latestRepositoryMigrations, (migration) => presentState(migration.state))) 301 | .map(([state, migrations]) => [state.toString().toLowerCase(), migrations.length]) 302 | ); 303 | 304 | const countsAsString = Object.entries(countsByState).map(([state, count]) => `${count} ${state}`).join(', '); 305 | logInfo(`Current stats: ${countsAsString || getNoMigrationsFoundMessage(since)}`); 306 | 307 | if (!isFirstRun) { 308 | // Log for any state changes 309 | for (const alreadyKnownMigration of repositoryMigrationsState) { 310 | const latestVersionOfAlreadyKnownMigration = latestRepositoryMigrations.find((migration) => migration.id === alreadyKnownMigration.id); 311 | 312 | // If we've seen this migration before, and it's recorded in our state... 313 | if (latestVersionOfAlreadyKnownMigration) { 314 | const stateChanged = latestVersionOfAlreadyKnownMigration.state !== alreadyKnownMigration.state; 315 | 316 | // Record (roughly) when the migration started if it has started since our last check 317 | if (stateChanged && !repositoryMigrationStartedAt.has(latestVersionOfAlreadyKnownMigration.id) && isMigrationStarted(latestVersionOfAlreadyKnownMigration)) { 318 | repositoryMigrationStartedAt.set(latestVersionOfAlreadyKnownMigration.id, new Date()); 319 | } 320 | 321 | // Log state changes to the log 322 | if (latestVersionOfAlreadyKnownMigration.state !== alreadyKnownMigration.state) { 323 | if (latestVersionOfAlreadyKnownMigration.state === 'FAILED') { 324 | logFailedMigration(latestVersionOfAlreadyKnownMigration); 325 | } else if (latestVersionOfAlreadyKnownMigration.state === 'SUCCEEDED') { 326 | logSuccessfulMigration(latestVersionOfAlreadyKnownMigration); 327 | logMigrationWarnings(latestVersionOfAlreadyKnownMigration); 328 | } else { 329 | logInfo(`changed state: ${presentState(alreadyKnownMigration.state)} ➡️ ${presentState(latestVersionOfAlreadyKnownMigration.state)}`, latestVersionOfAlreadyKnownMigration); 330 | } 331 | } 332 | } 333 | } 334 | 335 | // Log for new migrations 336 | const newMigrations = latestRepositoryMigrations.filter((currentMigration) => !repositoryMigrationsState.find((existingMigration) => existingMigration.id === currentMigration.id)); 337 | 338 | for (const newMigration of newMigrations) { 339 | // Record (roughly) when the migration started because it is new and has started 340 | if (!repositoryMigrationStartedAt.has(newMigration.id) && isMigrationStarted(newMigration)) { 341 | repositoryMigrationStartedAt.set(newMigration.id, new Date()); 342 | } 343 | 344 | if (newMigration.state === 'FAILED') { 345 | logFailedMigration(newMigration); 346 | } else if (newMigration.state === 'SUCCEEDED') { 347 | logSuccessfulMigration(newMigration); 348 | logMigrationWarnings(newMigration); 349 | } else if (newMigration.state === 'QUEUED') { 350 | logInfo(`was queued`, newMigration); 351 | } else { 352 | logInfo(`was queued and is currently ${presentState(newMigration.state)}`, newMigration); 353 | } 354 | } 355 | } 356 | 357 | // Update the state and render the UI 358 | repositoryMigrationsState = latestRepositoryMigrations; 359 | updateScreen(repositoryMigrationsState, repositoryMigrationStartedAt); 360 | 361 | /// Start all over again - but it definitely isn't the first run this time 362 | setTimeout(() => updateRepositoryMigration(false), intervalInMilliseconds); 363 | } 364 | 365 | // Start the first run, grabbing data and rendering the UI 366 | updateRepositoryMigration(true); 367 | })(); 368 | 369 | screen.render(); -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | const { combine, timestamp, printf, colorize } = winston.format; 3 | 4 | const customFormat = printf(({ level, message, timestamp }) => { 5 | return `${timestamp} ${level}: ${message}`; 6 | }); 7 | 8 | export default winston.createLogger({ 9 | format: combine( 10 | colorize(), 11 | timestamp(), 12 | customFormat 13 | ), 14 | transports: [ 15 | new winston.transports.Console() 16 | ] 17 | }); -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Opts { 2 | githubToken?: string; 3 | organization: string; 4 | intervalInSeconds: number; 5 | since?: string; 6 | } 7 | 8 | export interface RepositoryMigration { 9 | id: string; 10 | createdAt: string; 11 | failureReason?: string; 12 | repositoryName: string; 13 | state: string; 14 | } 15 | 16 | // Taken from https://docs.github.com/en/graphql/reference/enums#migrationstate 17 | export enum MigrationState { 18 | QUEUED = 'QUEUED', 19 | IN_PROGRESS = 'IN_PROGRESS', 20 | SUCCEEDED = 'SUCCEEDED', 21 | FAILED = 'FAILED', 22 | PENDING_VALIDATION = 'PENDING_VALIDATION', 23 | NOT_STARTED = 'NOT_STARTED', 24 | FAILED_VALIDATION = 'FAILED_VALIDATION' 25 | }; -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { RequestError } from "@octokit/request-error"; 2 | import { GraphqlResponseError } from "@octokit/graphql"; 3 | 4 | export const presentState = (state: string): string => state.replace('_', ' ').toLowerCase(); 5 | 6 | export const serializeError = (e: unknown): string => { 7 | if (typeof e === 'string') return e; 8 | if (e instanceof RequestError) return e.message; 9 | if (e instanceof GraphqlResponseError) return e.message; 10 | return JSON.stringify(e); 11 | } 12 | 13 | export const getRequestIdFromError = (e: unknown): string | undefined => { 14 | if (e instanceof GraphqlResponseError) return e.headers["x-github-request-id"]; 15 | if (e instanceof RequestError) return e.response?.headers["x-github-request-id"]; 16 | return undefined; 17 | }; 18 | 19 | const isValidDate = (date: unknown): boolean => date instanceof Date && !isNaN(date.getTime()); 20 | 21 | export const parseSince = (since: string): Date => { 22 | if (since === 'now') { 23 | return new Date(); 24 | } else if (since.match(/^\d{4}-\d{2}-\d{2}$/)) { 25 | // Passing a date without time will default to midnight UTC, when we want to use 26 | // local time if a specific time zone is not specified 27 | return new Date(`${since}T00:00:00`); 28 | } else { 29 | const dateFromInput = new Date(since); 30 | 31 | if (isValidDate(dateFromInput)) { 32 | return new Date(since); 33 | } else { 34 | throw 'The provided date seems to be invalid. Please provide a valid ISO 8601 date or datetime, or use "now" to use the current date.'; 35 | } 36 | } 37 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "resolveJsonModule": true 6 | }, 7 | "include": ["src"], 8 | "exclude": ["node_modules"] 9 | } 10 | --------------------------------------------------------------------------------