diff --git a/.gitignore b/.gitignore index a465620..54f5823 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ **/.DS_Store *.pem *.json +!/scripts/*/package*.json node_modules* test*.js test*.sh diff --git a/scripts/README.md b/scripts/README.md index e3c3673..a0ed54b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -91,6 +91,10 @@ My use case is to use this list to determine who needs to be added to a organiza 1. Run: `./new-users-to-add-to-project.sh ` 2. Don't delete the `` as it functions as your user database +## migrate-discussions + +See: [migrate-discussions](./migrate-discussions/README.md) + ## migrate-docker-containers-between-github-instances.sh Migrate Docker Containers in GitHub Packages (GitHub Container Registry) from one GitHub organization to another. diff --git a/scripts/migrate-discussions/README.md b/scripts/migrate-discussions/README.md new file mode 100644 index 0000000..79a52ce --- /dev/null +++ b/scripts/migrate-discussions/README.md @@ -0,0 +1,170 @@ +# migrate-discussions.js + +Migrate GitHub Discussions between repositories, including categories, labels, comments, and replies. This script can migrate discussions across different GitHub instances and enterprises with comprehensive rate limit handling and resume capabilities. + +## Prerequisites + +- `SOURCE_TOKEN` environment variable with GitHub PAT that has `repo` scope and read access to source repository discussions + - Alternatively, use a GitHub App token (recommended for better rate limits and security) +- `TARGET_TOKEN` environment variable with GitHub PAT that has `repo` scope and write access to target repository discussions + - Alternatively, use a GitHub App token (recommended for better rate limits, security, and authorship) (✨ **recommended for target token!**) +- Dependencies installed via `npm i` +- Both source and target repositories must have GitHub Discussions enabled + +### Using a GitHub App (Recommended) + +GitHub Apps provide better rate limits and security compared to personal access tokens. To use a GitHub App: + +1. Create or use an existing GitHub App with `repo` permissions +2. Install the app on the source and/or target repositories +3. Generate a token using the GitHub CLI and [`gh-token`](https://github.com/Link-/gh-token) extension: + +```bash +export SOURCE_TOKEN=$(gh token generate --app-id YOUR_SOURCE_APP_ID --installation-id YOUR_SOURCE_INSTALLATION_ID --key /path/to/source/private-key.pem --token-only) +export TARGET_TOKEN=$(gh token generate --app-id YOUR_TARGET_APP_ID --installation-id YOUR_TARGET_INSTALLATION_ID --key /path/to/target/private-key.pem --token-only) +``` + +## Script usage + +Basic usage: + +```bash +export SOURCE_TOKEN=ghp_abc +export TARGET_TOKEN=ghp_xyz +# export SOURCE_TOKEN=$(gh token generate --app-id YOUR_SOURCE_APP_ID --installation-id YOUR_SOURCE_INSTALLATION_ID --key /path/to/source/private-key.pem --token-only) +# export TARGET_TOKEN=$(gh token generate --app-id YOUR_TARGET_APP_ID --installation-id YOUR_TARGET_INSTALLATION_ID --key /path/to/target/private-key.pem --token-only) +# export SOURCE_API_URL= # if GHES +# export TARGET_API_URL= # if GHES/ghe.com +cd ./scripts/migrate-discussions +npm i +node ./migrate-discussions.js source-org source-repo target-org target-repo +``` + +Resume from a specific discussion number (useful if interrupted): + +```bash +node ./migrate-discussions.js source-org source-repo target-org target-repo --start-from 50 +``` + +## Optional environment variables + +- `SOURCE_API_URL` - API endpoint for source (defaults to `https://api.github.com`) +- `TARGET_API_URL` - API endpoint for target (defaults to `https://api.github.com`) + +Example with GitHub Enterprise Server: + +```bash +export SOURCE_API_URL=https://github.mycompany.com/api/v3 +export TARGET_API_URL=https://api.github.com +export SOURCE_TOKEN=ghp_abc +export TARGET_TOKEN=ghp_xyz +node ./migrate-discussions.js source-org source-repo target-org target-repo +``` + +## Features + +### Content Migration + +- Automatically creates missing discussion categories in the target repository +- Creates labels in the target repository if they don't exist +- Copies all comments and threaded replies with proper attribution +- Copies poll results as static snapshots (with table and optional Mermaid chart) +- Preserves reaction counts on discussions, comments, and replies +- Maintains locked status of discussions +- Indicates pinned discussions with a visual indicator +- Marks answered discussions and preserves the accepted answer + +### Rate limiting and reliability + +- **Automatic rate limit handling** with Octokit's built-in throttling plugin +- **Intelligent retry logic** with configurable retries for both rate-limit and non-rate-limit errors +- **GitHub-recommended delays** - 3 seconds between discussions/comments to stay under secondary rate limits +- **Resume capability** - Use `--start-from ` to resume from a specific discussion if interrupted +- **Rate limit tracking** - Summary shows how many times primary and secondary rate limits were hit + +### User experience + +- Colored console output with timestamps for better visibility +- Comprehensive summary statistics at completion +- Detailed progress logging for each discussion, comment, and reply + +## Configuration options + +Edit these constants at the top of the script: + +- `INCLUDE_POLL_MERMAID_CHART` - Set to `false` to disable Mermaid pie charts for polls (default: `true`) +- `RATE_LIMIT_SLEEP_SECONDS` - Sleep duration between API calls (default: `0.5` seconds) +- `DISCUSSION_PROCESSING_DELAY_SECONDS` - Delay between processing discussions/comments (default: `3` seconds) +- `MAX_RETRIES` - Maximum retries for both rate-limit and non-rate-limit errors (default: `15`) + +## Summary output + +After completion, the script displays comprehensive statistics: + +- Total discussions found and created +- Discussions skipped (when using `--start-from`) +- Total comments found and copied +- **Primary rate limits hit** - How many times the script hit GitHub's primary rate limit +- **Secondary rate limits hit** - How many times the script hit GitHub's secondary rate limit +- List of missing categories that need manual creation + +### Example summary output + +```text +[2025-10-02 19:38:44] ============================================================ +[2025-10-02 19:38:44] Discussion copy completed! +[2025-10-02 19:38:44] Total discussions found: 10 +[2025-10-02 19:38:44] Discussions created: 10 +[2025-10-02 19:38:44] Discussions skipped: 0 +[2025-10-02 19:38:44] Total comments found: 9 +[2025-10-02 19:38:44] Comments copied: 9 +[2025-10-02 19:38:44] Primary rate limits hit: 0 +[2025-10-02 19:38:44] Secondary rate limits hit: 0 +[2025-10-02 19:38:44] WARNING: +The following categories were missing and need to be created manually: +[2025-10-02 19:38:44] WARNING: - Blog posts! +[2025-10-02 19:38:44] WARNING: +[2025-10-02 19:38:44] WARNING: To create categories manually: +[2025-10-02 19:38:44] WARNING: 1. Go to https://github.com/joshjohanning-emu/discussions-test/discussions +[2025-10-02 19:38:44] WARNING: 2. Click 'New discussion' +[2025-10-02 19:38:44] WARNING: 3. Look for category management options +[2025-10-02 19:38:44] WARNING: 4. Create the missing categories with appropriate names and descriptions +[2025-10-02 19:38:44] +All done! ✨ +``` + +## Notes + +### Category handling + +- If a category doesn't exist in the target repository, discussions will be created in the "General" category as a fallback +- Missing categories are tracked and reported at the end of the script + +### Content preservation + +- The script preserves discussion metadata by adding attribution text to the body and comments +- Poll results are copied as static snapshots - voting is not available in copied discussions +- Reactions are copied as read-only summaries (users cannot add new reactions to the migrated content) +- Attachments (images and files) will not copy over and require manual handling + +### Discussion states + +- Locked discussions will be locked in the target repository +- Closed discussions will be closed in the target repository +- Answered discussions will have the same comment marked as the answer +- Pinned status is indicated in the discussion body (GitHub API doesn't allow pinning via GraphQL) + +### Rate limiting + +- GitHub limits content-generating requests to avoid abuse + - No more than 80 content-generating requests per minute + - No more than 500 content-generating requests per hour +- The script stays under 1 discussion or comment created every 3 seconds (GitHub's recommendation) +- Automatic retry with wait times from GitHub's `retry-after` headers +- If rate limits are consistently hit, the script will retry up to 15 times before failing + +### Resume capability + +- Use `--start-from ` to skip discussions before a specific discussion number +- Useful for resuming after interruptions or failures +- Discussion numbers are the user-friendly numbers (e.g., #50), not GraphQL IDs diff --git a/scripts/migrate-discussions/migrate-discussions.js b/scripts/migrate-discussions/migrate-discussions.js new file mode 100644 index 0000000..1cb2481 --- /dev/null +++ b/scripts/migrate-discussions/migrate-discussions.js @@ -0,0 +1,1284 @@ +#!/usr/bin/env node + +// +// Copy Discussions between repositories in different enterprises +// This script copies discussions from a source repository to a target repository +// using different GitHub tokens for authentication to support cross-enterprise copying +// +// Usage: +// node migrate-discussions.js +// +// Example: +// node migrate-discussions.js source-org repo1 target-org repo2 +// +// Prerequisites: +// - SOURCE_TOKEN environment variable with read access to source repository discussions +// - TARGET_TOKEN environment variable with write access to target repository discussions +// - Both tokens must have the 'repo' scope +// - Dependencies installed via `npm i octokit` +// +// Optional Environment Variables: +// - SOURCE_API_URL: API endpoint for source (defaults to https://api.github.com) +// - TARGET_API_URL: API endpoint for target (defaults to https://api.github.com) +// +// Note: This script copies discussion content, comments, replies, polls, reactions, locked status, +// and pinned status. Reactions are copied as read-only summaries. +// Attachments (images and files) will not copy over - they need manual handling. +// +// Secondary Rate Limit Guidelines: +// GitHub limits content-generating requests to avoid abuse: +// - No more than 80 content-generating requests per minute +// - No more than 500 content-generating requests per hour +// - Try to stay under 1 discussion or comment created every 3 seconds +// This script includes automatic retry logic and rate limit handling to stay within these limits. +// See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-rate-limits + +// Configuration +const INCLUDE_POLL_MERMAID_CHART = true; // Set to false to disable Mermaid pie chart for polls +const RATE_LIMIT_SLEEP_SECONDS = 0.5; // Default sleep duration between API calls to avoid rate limiting +const DISCUSSION_PROCESSING_DELAY_SECONDS = 3; // Delay between processing discussions (GitHub recommends 1 discussion per 3 seconds) +const MAX_RETRIES = 15; // Maximum number of retries for both rate-limit and non-rate-limit errors + +const { Octokit } = require("octokit"); + +// Parse command line arguments +const args = process.argv.slice(2); + +// Check for help flag +if (args.includes('--help') || args.includes('-h')) { + console.log('Copy Discussions between GitHub repositories'); + console.log(''); + console.log('Usage:'); + console.log(' node migrate-discussions.js [options]'); + console.log(''); + console.log('Arguments:'); + console.log(' source_org Source organization name'); + console.log(' source_repo Source repository name'); + console.log(' target_org Target organization name'); + console.log(' target_repo Target repository name'); + console.log(''); + console.log('Options:'); + console.log(' --start-from Start copying from a specific discussion number (useful for resuming)'); + console.log(''); + console.log('Environment Variables (Required):'); + console.log(' SOURCE_TOKEN GitHub token with read access to source repository discussions'); + console.log(' TARGET_TOKEN GitHub token with write access to target repository discussions'); + console.log(''); + console.log('Environment Variables (Optional):'); + console.log(' SOURCE_API_URL API endpoint for source (defaults to https://api.github.com)'); + console.log(' TARGET_API_URL API endpoint for target (defaults to https://api.github.com)'); + console.log(''); + console.log('Example:'); + console.log(' node migrate-discussions.js source-org repo1 target-org repo2'); + console.log(''); + console.log('Example with resume from discussion #50:'); + console.log(' node migrate-discussions.js source-org repo1 target-org repo2 --start-from 50'); + console.log(''); + console.log('Example with GHES:'); + console.log(' SOURCE_API_URL=https://github.mycompany.com/api/v3 \\'); + console.log(' TARGET_API_URL=https://api.github.com \\'); + console.log(' SOURCE_TOKEN=ghp_xxx \\'); + console.log(' TARGET_TOKEN=ghp_yyy \\'); + console.log(' node migrate-discussions.js source-org repo1 target-org repo2'); + console.log(''); + console.log('Note:'); + console.log(' - Both tokens must have the "repo" scope'); + console.log(' - This script copies discussion content, comments, replies, polls, reactions,'); + console.log(' locked status, and pinned status'); + console.log(' - Attachments (images and files) will not copy over and require manual handling'); + console.log(' - Use --start-from to resume from a specific discussion in case of interruption'); + process.exit(0); +} + +// Parse --start-from option +let startFromNumber = null; +const startFromIndex = args.indexOf('--start-from'); +if (startFromIndex !== -1) { + if (startFromIndex + 1 >= args.length) { + console.error("ERROR: --start-from requires a discussion number"); + process.exit(1); + } + startFromNumber = parseInt(args[startFromIndex + 1], 10); + if (isNaN(startFromNumber) || startFromNumber < 1) { + console.error("ERROR: --start-from must be a positive integer"); + process.exit(1); + } + // Remove the option and its value from args + args.splice(startFromIndex, 2); +} + +if (args.length !== 4) { + console.error("Usage: node migrate-discussions.js [--start-from ]"); + console.error("\nExample:"); + console.error(" node migrate-discussions.js source-org repo1 target-org repo2"); + console.error("\nExample with resume:"); + console.error(" node migrate-discussions.js source-org repo1 target-org repo2 --start-from 50"); + console.error("\nFor more information, use --help"); + process.exit(1); +} + +const [SOURCE_ORG, SOURCE_REPO, TARGET_ORG, TARGET_REPO] = args; + +// Validate environment variables +if (!process.env.SOURCE_TOKEN) { + console.error("ERROR: SOURCE_TOKEN environment variable is required"); + process.exit(1); +} + +if (!process.env.TARGET_TOKEN) { + console.error("ERROR: TARGET_TOKEN environment variable is required"); + process.exit(1); +} + +// Get API endpoints from environment variables (optional) +const SOURCE_API_URL = process.env.SOURCE_API_URL || 'https://api.github.com'; +const TARGET_API_URL = process.env.TARGET_API_URL || 'https://api.github.com'; + +// Configure throttling for rate limit handling +// Octokit's throttling plugin automatically handles both REST and GraphQL rate limits +// by intercepting HTTP 403 responses and retry-after headers +const throttleOptions = { + onRateLimit: (retryAfter, options, octokit) => { + primaryRateLimitHits++; + warn(`Primary rate limit exhausted for request ${options.method} ${options.url}`); + if (options.request.retryCount < MAX_RETRIES) { + warn(`Retrying after ${retryAfter} seconds (retry ${options.request.retryCount + 1}/${MAX_RETRIES})`); + return true; + } + error(`Max retries reached for rate limit`); + return false; + }, + onSecondaryRateLimit: (retryAfter, options, octokit) => { + secondaryRateLimitHits++; + warn(`Secondary rate limit detected for request ${options.method} ${options.url}`); + if (options.request.retryCount < MAX_RETRIES) { + warn(`Retrying after ${retryAfter} seconds (retry ${options.request.retryCount + 1}/${MAX_RETRIES})`); + return true; + } + error(`Max retries reached for secondary rate limit`); + return false; + } +}; + +// Initialize Octokit instances with throttling enabled +const sourceOctokit = new Octokit({ + auth: process.env.SOURCE_TOKEN, + baseUrl: SOURCE_API_URL, + throttle: throttleOptions +}); + +const targetOctokit = new Octokit({ + auth: process.env.TARGET_TOKEN, + baseUrl: TARGET_API_URL, + throttle: throttleOptions +}); + +// Tracking variables +let missingCategories = []; +let totalDiscussions = 0; +let createdDiscussions = 0; +let skippedDiscussions = 0; +let totalComments = 0; +let copiedComments = 0; +let primaryRateLimitHits = 0; +let secondaryRateLimitHits = 0; + +// Helper functions +function log(message) { + const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0]; + console.log(`\x1b[32m[${timestamp}]\x1b[0m ${message}`); +} + +function warn(message) { + const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0]; + console.warn(`\x1b[33m[${timestamp}] WARNING:\x1b[0m ${message}`); +} + +function error(message) { + const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0]; + console.error(`\x1b[31m[${timestamp}] ERROR:\x1b[0m ${message}`); +} + +async function sleep(seconds) { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} + +async function rateLimitSleep(seconds = RATE_LIMIT_SLEEP_SECONDS) { + log(`Waiting ${seconds}s to avoid rate limiting...`); + await sleep(seconds); +} + +function formatPollData(poll) { + if (!poll || !poll.options || poll.options.nodes.length === 0) { + return ''; + } + + const options = poll.options.nodes; + const totalVotes = poll.totalVoteCount || 0; + + let pollMarkdown = '\n\n---\n\n### 📊 Poll Results (from source discussion)\n\n'; + pollMarkdown += `**${poll.question}**\n\n`; + + // Create table + pollMarkdown += '| Option | Votes | Percentage |\n'; + pollMarkdown += '|--------|-------|------------|\n'; + + options.forEach(option => { + const votes = option.totalVoteCount || 0; + const percentage = totalVotes > 0 ? ((votes / totalVotes) * 100).toFixed(1) : '0.0'; + pollMarkdown += `| ${option.option} | ${votes} | ${percentage}% |\n`; + }); + + pollMarkdown += `\n**Total votes:** ${totalVotes}\n`; + + // Add Mermaid pie chart if enabled + if (INCLUDE_POLL_MERMAID_CHART && totalVotes > 0) { + pollMarkdown += '\n
\nVisual representation\n\n'; + pollMarkdown += '```mermaid\n'; + pollMarkdown += '%%{init: {"pie": {"textPosition": 0.5}, "themeVariables": {"pieOuterStrokeWidth": "5px"}}}%%\n'; + pollMarkdown += 'pie showData\n'; + pollMarkdown += ` title ${poll.question}\n`; + + options.forEach(option => { + const votes = option.totalVoteCount || 0; + if (votes > 0) { + // Escape backslashes and quotes in option text for Mermaid + const escapedOption = option.option.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + pollMarkdown += ` "${escapedOption}" : ${votes}\n`; + } + }); + + pollMarkdown += '```\n\n'; + pollMarkdown += '
\n'; + } + + pollMarkdown += '\n_Note: This is a static snapshot of poll results from the source discussion. Voting is not available in copied discussions._\n'; + + return pollMarkdown; +} + +function formatReactions(reactionGroups) { + if (!reactionGroups || reactionGroups.length === 0) { + return ''; + } + + const reactionMap = { + 'THUMBS_UP': '👍', + 'THUMBS_DOWN': '👎', + 'LAUGH': '😄', + 'HOORAY': '🎉', + 'CONFUSED': '😕', + 'HEART': 'â¤ī¸', + 'ROCKET': '🚀', + 'EYES': '👀' + }; + + const formattedReactions = reactionGroups + .filter(group => group.users.totalCount > 0) + .map(group => { + const emoji = reactionMap[group.content] || group.content; + return `${emoji} ${group.users.totalCount}`; + }) + .join(' | '); + + return formattedReactions ? `\n\n**Reactions:** ${formattedReactions}` : ''; +} + +// GraphQL Queries and Mutations +const CHECK_DISCUSSIONS_ENABLED_QUERY = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + hasDiscussionsEnabled + id + } + } +`; + +const FETCH_CATEGORIES_QUERY = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + discussionCategories(first: 100) { + nodes { + id + name + slug + emoji + description + } + } + } + } +`; + +const FETCH_LABELS_QUERY = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + labels(first: 100) { + nodes { + id + name + color + description + } + } + } + } +`; + +const FETCH_DISCUSSIONS_QUERY = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + discussions(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + body + category { + id + name + slug + description + emoji + } + labels(first: 100) { + nodes { + id + name + color + description + } + } + answer { + id + } + author { + login + } + createdAt + closed + locked + upvoteCount + url + number + poll { + question + totalVoteCount + options(first: 100) { + nodes { + option + totalVoteCount + } + } + } + reactionGroups { + content + users { + totalCount + } + } + } + } + pinnedDiscussions(first: 100) { + nodes { + discussion { + id + } + } + } + } + } +`; + +const FETCH_DISCUSSION_COMMENTS_QUERY = ` + query($discussionId: ID!) { + node(id: $discussionId) { + ... on Discussion { + comments(first: 100) { + nodes { + id + body + author { + login + } + createdAt + upvoteCount + reactionGroups { + content + users { + totalCount + } + } + replies(first: 50) { + nodes { + id + body + author { + login + } + createdAt + upvoteCount + reactionGroups { + content + users { + totalCount + } + } + } + } + } + } + } + } + } +`; + +const CREATE_DISCUSSION_MUTATION = ` + mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repositoryId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { + id + title + url + number + } + } + } +`; + +const CREATE_LABEL_MUTATION = ` + mutation($repositoryId: ID!, $name: String!, $color: String!, $description: String) { + createLabel(input: { + repositoryId: $repositoryId, + name: $name, + color: $color, + description: $description + }) { + label { + id + name + } + } + } +`; + +const ADD_LABELS_MUTATION = ` + mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { + labelableId: $labelableId, + labelIds: $labelIds + }) { + labelable { + labels(first: 100) { + nodes { + name + } + } + } + } + } +`; + +const ADD_DISCUSSION_COMMENT_MUTATION = ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { + discussionId: $discussionId, + body: $body + }) { + comment { + id + body + createdAt + } + } + } +`; + +const ADD_DISCUSSION_COMMENT_REPLY_MUTATION = ` + mutation($discussionId: ID!, $replyToId: ID!, $body: String!) { + addDiscussionComment(input: { + discussionId: $discussionId, + replyToId: $replyToId, + body: $body + }) { + comment { + id + body + createdAt + } + } + } +`; + +const CLOSE_DISCUSSION_MUTATION = ` + mutation($discussionId: ID!, $reason: DiscussionCloseReason) { + closeDiscussion(input: { + discussionId: $discussionId, + reason: $reason + }) { + discussion { + id + closed + } + } + } +`; + +const LOCK_DISCUSSION_MUTATION = ` + mutation($discussionId: ID!) { + lockLockable(input: { + lockableId: $discussionId + }) { + lockedRecord { + locked + } + } + } +`; + +const MARK_DISCUSSION_COMMENT_AS_ANSWER_MUTATION = ` + mutation($commentId: ID!) { + markDiscussionCommentAsAnswer(input: { + id: $commentId + }) { + discussion { + id + } + } + } +`; + +// Main functions +async function checkDiscussionsEnabled(octokit, owner, repo) { + log(`Checking if discussions are enabled in ${owner}/${repo}...`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(CHECK_DISCUSSIONS_ENABLED_QUERY, { + owner, + repo + }); + + if (!response.repository.hasDiscussionsEnabled) { + error(`Discussions are not enabled in ${owner}/${repo}`); + return null; + } + + log(`✓ Discussions are enabled in ${owner}/${repo}`); + return response.repository.id; + } catch (err) { + error(`Failed to check discussions status: ${err.message}`); + throw err; + } +} + +async function fetchCategories(octokit, owner, repo) { + log(`Fetching categories from ${owner}/${repo}...`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(FETCH_CATEGORIES_QUERY, { + owner, + repo + }); + + const categories = response.repository.discussionCategories.nodes; + log(`Found ${categories.length} categories`); + + return categories; + } catch (err) { + error(`Failed to fetch categories: ${err.message}`); + throw err; + } +} + +async function fetchLabels(octokit, owner, repo) { + log(`Fetching labels from ${owner}/${repo}...`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(FETCH_LABELS_QUERY, { + owner, + repo + }); + + const labels = response.repository.labels.nodes; + log(`Found ${labels.length} labels`); + + return labels; + } catch (err) { + error(`Failed to fetch labels: ${err.message}`); + throw err; + } +} + +function findCategoryId(categories, categoryName, categorySlug) { + const category = categories.find(c => + c.name === categoryName || c.slug === categorySlug + ); + + return category ? category.id : null; +} + +function getCategoryIdOrFallback(categories, categoryName, categorySlug) { + let categoryId = findCategoryId(categories, categoryName, categorySlug); + + if (categoryId) { + return categoryId; + } + + warn(`Category '${categoryName}' (${categorySlug}) not found in target repository`); + + // Track missing category + if (!missingCategories.includes(categoryName)) { + missingCategories.push(categoryName); + } + + // Try to find "General" category + const generalCategory = categories.find(c => + c.name === "General" || c.slug === "general" + ); + + if (generalCategory) { + warn(`Using 'General' category as fallback for '${categoryName}'`); + return generalCategory.id; + } + + // Use first category as last resort + if (categories.length > 0) { + warn(`Using '${categories[0].name}' category as fallback for '${categoryName}'`); + return categories[0].id; + } + + error("No available categories found in target repository"); + return null; +} + +function findLabelId(labels, labelName) { + const label = labels.find(l => l.name === labelName); + return label ? label.id : null; +} + +async function createLabel(octokit, repositoryId, name, color, description, targetLabels) { + log(`Creating new label: '${name}'`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(CREATE_LABEL_MUTATION, { + repositoryId, + name, + color, + description + }); + + const newLabel = response.createLabel.label; + log(`✓ Created label '${name}' with ID: ${newLabel.id}`); + + // Update local cache + targetLabels.push({ + id: newLabel.id, + name, + color, + description + }); + + return newLabel.id; + } catch (err) { + error(`Failed to create label '${name}': ${err.message}`); + return null; + } +} + +async function getOrCreateLabelId(octokit, repositoryId, labelName, labelColor, labelDescription, targetLabels) { + let labelId = findLabelId(targetLabels, labelName); + + if (labelId) { + return labelId; + } + + return await createLabel(octokit, repositoryId, labelName, labelColor, labelDescription, targetLabels); +} + +async function addLabelsToDiscussion(octokit, discussionId, labelIds) { + if (labelIds.length === 0) { + return true; + } + + log(`Adding ${labelIds.length} labels to discussion`); + + await rateLimitSleep(); + + try { + await octokit.graphql(ADD_LABELS_MUTATION, { + labelableId: discussionId, + labelIds + }); + + log("✓ Successfully added labels to discussion"); + return true; + } catch (err) { + error(`Failed to add labels to discussion: ${err.message}`); + return false; + } +} + +async function createDiscussion(octokit, repositoryId, categoryId, title, body, sourceUrl, sourceAuthor, sourceCreated, poll = null, locked = false, isPinned = false, reactionGroups = []) { + let enhancedBody = body; + + // Add pinned indicator if discussion was pinned + if (isPinned) { + enhancedBody = `📌 _This discussion was pinned in the source repository_\n\n${enhancedBody}`; + } + + // Add reactions if present + const reactionsMarkdown = formatReactions(reactionGroups); + if (reactionsMarkdown) { + enhancedBody += reactionsMarkdown; + } + + // Add poll data if present + if (poll) { + const pollMarkdown = formatPollData(poll); + enhancedBody += pollMarkdown; + } + + // Add metadata + enhancedBody += `\n\n
\nOriginal discussion metadata\n\n_Original discussion by @${sourceAuthor} on ${sourceCreated}_\n_Source: ${sourceUrl}_\n${locked ? '\n_🔒 This discussion was locked in the source repository_' : ''}\n
`; + + log(`Creating discussion: '${title}'`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(CREATE_DISCUSSION_MUTATION, { + repositoryId, + categoryId, + title, + body: enhancedBody + }); + + const newDiscussion = response.createDiscussion.discussion; + + // Lock the discussion if it was locked in the source + if (locked) { + await lockDiscussion(octokit, newDiscussion.id); + } + + return newDiscussion; + } catch (err) { + error(`Failed to create discussion: ${err.message}`); + throw err; + } +} + +async function lockDiscussion(octokit, discussionId) { + log(`Locking discussion ${discussionId}...`); + + await rateLimitSleep(); + + try { + await octokit.graphql(LOCK_DISCUSSION_MUTATION, { + discussionId + }); + log(`Discussion locked successfully`); + } catch (err) { + error(`Failed to lock discussion: ${err.message}`); + } +} + +async function fetchDiscussionComments(octokit, discussionId) { + log(`Fetching comments for discussion ${discussionId}...`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(FETCH_DISCUSSION_COMMENTS_QUERY, { + discussionId + }); + + return response.node.comments.nodes || []; + } catch (err) { + error(`Failed to fetch comments: ${err.message}`); + return []; + } +} + +async function addDiscussionComment(octokit, discussionId, body, originalAuthor, originalCreated, reactionGroups = []) { + let enhancedBody = body; + + // Add reactions if present + const reactionsMarkdown = formatReactions(reactionGroups); + if (reactionsMarkdown) { + enhancedBody += reactionsMarkdown; + } + + enhancedBody += `\n\n
\nOriginal comment metadata\n\n_Original comment by @${originalAuthor} on ${originalCreated}_\n
`; + + log("Adding comment to discussion"); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(ADD_DISCUSSION_COMMENT_MUTATION, { + discussionId, + body: enhancedBody + }); + + const commentId = response.addDiscussionComment.comment.id; + log(`✓ Added comment with ID: ${commentId}`); + return commentId; + } catch (err) { + error(`Failed to add comment: ${err.message}`); + return null; + } +} + +async function addDiscussionCommentReply(octokit, discussionId, replyToId, body, originalAuthor, originalCreated, reactionGroups = []) { + let enhancedBody = body; + + // Add reactions if present + const reactionsMarkdown = formatReactions(reactionGroups); + if (reactionsMarkdown) { + enhancedBody += reactionsMarkdown; + } + + enhancedBody += `\n\n
\nOriginal reply metadata\n\n_Original reply by @${originalAuthor} on ${originalCreated}_\n
`; + + log(`Adding reply to comment ${replyToId}`); + + await rateLimitSleep(); + + try { + const response = await octokit.graphql(ADD_DISCUSSION_COMMENT_REPLY_MUTATION, { + discussionId, + replyToId, + body: enhancedBody + }); + + const replyId = response.addDiscussionComment.comment.id; + log(`✓ Added reply with ID: ${replyId}`); + return replyId; + } catch (err) { + error(`Failed to add reply: ${err.message}`); + return null; + } +} + +async function closeDiscussion(octokit, discussionId) { + log("Closing discussion..."); + + await rateLimitSleep(); + + try { + await octokit.graphql(CLOSE_DISCUSSION_MUTATION, { + discussionId, + reason: "RESOLVED" + }); + + log("✓ Discussion closed"); + return true; + } catch (err) { + error(`Failed to close discussion: ${err.message}`); + return false; + } +} + +async function markCommentAsAnswer(octokit, commentId) { + log("Marking comment as answer..."); + + await rateLimitSleep(); + + try { + await octokit.graphql(MARK_DISCUSSION_COMMENT_AS_ANSWER_MUTATION, { + commentId + }); + + log("✓ Comment marked as answer"); + return true; + } catch (err) { + error(`Failed to mark comment as answer: ${err.message}`); + return false; + } +} + +async function copyDiscussionComments(octokit, discussionId, comments, answerCommentId = null) { + if (!comments || comments.length === 0) { + log("No comments to copy for this discussion"); + return null; + } + + log(`Copying ${comments.length} comments...`); + totalComments += comments.length; + + // Map to track source comment ID to target comment ID + const commentIdMap = new Map(); + + for (const comment of comments) { + if (!comment.body) continue; + + const author = comment.author?.login || "unknown"; + const createdAt = comment.createdAt || ""; + + log(`Copying comment by @${author}`); + + const newCommentId = await addDiscussionComment( + octokit, + discussionId, + comment.body, + author, + createdAt, + comment.reactionGroups || [] + ); + + if (newCommentId) { + copiedComments++; + + // Track the mapping + commentIdMap.set(comment.id, newCommentId); + + // Copy replies if any + const replies = comment.replies?.nodes || []; + if (replies.length > 0) { + log(`Copying ${replies.length} replies to comment...`); + + for (const reply of replies) { + if (!reply.body) continue; + + const replyAuthor = reply.author?.login || "unknown"; + const replyCreated = reply.createdAt || ""; + + log(`Copying reply by @${replyAuthor}`); + + await addDiscussionCommentReply( + octokit, + discussionId, + newCommentId, + reply.body, + replyAuthor, + replyCreated, + reply.reactionGroups || [] + ); + + // Delay between replies to avoid rate limits (1 comment per 3 seconds) + await sleep(DISCUSSION_PROCESSING_DELAY_SECONDS); + } + } + + // Delay between comments to avoid rate limits (1 comment per 3 seconds) + await sleep(DISCUSSION_PROCESSING_DELAY_SECONDS); + } else { + warn(`Failed to copy comment by @${author}, skipping replies`); + } + } + + log("✓ Finished copying comments"); + + // Return the new comment ID if this was the answer comment + if (answerCommentId && commentIdMap.has(answerCommentId)) { + return commentIdMap.get(answerCommentId); + } + + return null; +} + +async function processDiscussionsPage(sourceOctokit, targetOctokit, owner, repo, targetRepoId, targetCategories, targetLabels, cursor = null, startFromNumber = null) { + log(`Fetching discussions page (cursor: ${cursor || "null"})...`); + + await rateLimitSleep(); + + try { + const response = await sourceOctokit.graphql(FETCH_DISCUSSIONS_QUERY, { + owner, + repo, + cursor + }); + + const discussions = response.repository.discussions.nodes; + const pageInfo = response.repository.discussions.pageInfo; + const pinnedDiscussions = response.repository.pinnedDiscussions.nodes || []; + + // Create a set of pinned discussion IDs for quick lookup + const pinnedDiscussionIds = new Set(pinnedDiscussions.map(p => p.discussion.id)); + + log(`Found ${discussions.length} discussions to process on this page`); + + for (const discussion of discussions) { + totalDiscussions++; + + // Skip discussions before the start-from number + if (startFromNumber !== null && discussion.number < startFromNumber) { + log(`Skipping discussion #${discussion.number}: '${discussion.title}' (before start-from #${startFromNumber})`); + skippedDiscussions++; + continue; + } + + log(`\n=== Processing discussion #${discussion.number}: '${discussion.title}' ===`); + + // Get or fallback category + const targetCategoryId = getCategoryIdOrFallback( + targetCategories, + discussion.category.name, + discussion.category.slug + ); + + if (!targetCategoryId) { + error(`No valid category found for discussion #${discussion.number}`); + skippedDiscussions++; + continue; + } + + // Check if discussion is pinned + const isPinned = pinnedDiscussionIds.has(discussion.id); + + // Create discussion (Octokit throttling plugin handles rate limits automatically) + let newDiscussion = null; + let createSuccess = false; + + for (let attempt = 1; attempt <= MAX_RETRIES && !createSuccess; attempt++) { + try { + newDiscussion = await createDiscussion( + targetOctokit, + targetRepoId, + targetCategoryId, + discussion.title, + discussion.body || "", + discussion.url, + discussion.author?.login || "unknown", + discussion.createdAt, + discussion.poll || null, + discussion.locked || false, + isPinned, + discussion.reactionGroups || [] + ); + + createSuccess = true; + createdDiscussions++; + log(`✓ Created discussion #${discussion.number}: '${discussion.title}'`); + + } catch (err) { + // Octokit throttling handles rate limits; this catches other errors + error(`Failed to create discussion #${discussion.number}: '${discussion.title}' - ${err.message}`); + + if (attempt < MAX_RETRIES) { + warn(`Retrying (attempt ${attempt + 1}/${MAX_RETRIES}) in 5 seconds...`); + await sleep(5); + } else { + error(`Max retries (${MAX_RETRIES}) reached. Skipping discussion #${discussion.number}.`); + skippedDiscussions++; + break; + } + } + } + + // If we exhausted retries without success, skip this discussion + if (!createSuccess) { + continue; + } + + // Log additional metadata info + if (discussion.poll && discussion.poll.options?.nodes?.length > 0) { + log(` â„šī¸ Poll included with ${discussion.poll.options.nodes.length} options (${discussion.poll.totalVoteCount} total votes)`); + } + if (discussion.locked) { + log(` 🔒 Discussion was locked in source and has been locked in target`); + } + if (isPinned) { + log(` 📌 Discussion was pinned in source (indicator added to body)`); + } + const totalReactions = discussion.reactionGroups?.reduce((sum, group) => sum + (group.users.totalCount || 0), 0) || 0; + if (totalReactions > 0) { + log(` â¤ī¸ ${totalReactions} reaction${totalReactions !== 1 ? 's' : ''} copied`); + } + + // Process labels + if (discussion.labels.nodes.length > 0) { + const labelIds = []; + + for (const label of discussion.labels.nodes) { + log(`Processing label: '${label.name}' (color: ${label.color})`); + + const labelId = await getOrCreateLabelId( + targetOctokit, + targetRepoId, + label.name, + label.color, + label.description || "", + targetLabels + ); + + if (labelId) { + labelIds.push(labelId); + } + } + + if (labelIds.length > 0) { + await addLabelsToDiscussion(targetOctokit, newDiscussion.id, labelIds); + } + } + + // Copy comments + log("Processing comments for discussion..."); + const comments = await fetchDiscussionComments(sourceOctokit, discussion.id); + const answerCommentId = discussion.answer?.id || null; + const newAnswerCommentId = await copyDiscussionComments( + targetOctokit, + newDiscussion.id, + comments, + answerCommentId + ); + + // Mark answer if applicable + if (newAnswerCommentId) { + log("Source discussion has an answer comment, marking it in target..."); + await markCommentAsAnswer(targetOctokit, newAnswerCommentId); + } + + // Close discussion if it was closed in source + if (discussion.closed) { + log("Source discussion is closed, closing target discussion..."); + await closeDiscussion(targetOctokit, newDiscussion.id); + } + + log(`✅ Finished processing discussion #${discussion.number}: '${discussion.title}'`); + + // Delay between discussions + await sleep(DISCUSSION_PROCESSING_DELAY_SECONDS); + } + + // Process next page if exists + if (pageInfo.hasNextPage) { + log(`Processing next page with cursor: ${pageInfo.endCursor}`); + await processDiscussionsPage( + sourceOctokit, + targetOctokit, + owner, + repo, + targetRepoId, + targetCategories, + targetLabels, + pageInfo.endCursor, + startFromNumber + ); + } else { + log("No more pages to process"); + } + + } catch (err) { + error(`Failed to fetch discussions: ${err.message}`); + throw err; + } +} + +// Main execution +async function main() { + try { + log("Starting discussion copy process..."); + log(`Source: ${SOURCE_ORG}/${SOURCE_REPO}`); + log(`Target: ${TARGET_ORG}/${TARGET_REPO}`); + if (startFromNumber !== null) { + log(`Resume mode: Starting from discussion #${startFromNumber}`); + } + log(""); + log("⚡ This script uses conservative rate limiting to avoid GitHub API limits"); + log(""); + + // Verify source repository + log("Verifying access to source repository..."); + const sourceRepoId = await checkDiscussionsEnabled(sourceOctokit, SOURCE_ORG, SOURCE_REPO); + if (!sourceRepoId) { + process.exit(1); + } + log(`Source repository ID: ${sourceRepoId}`); + + // Verify target repository + log("Getting target repository ID..."); + const targetRepoId = await checkDiscussionsEnabled(targetOctokit, TARGET_ORG, TARGET_REPO); + if (!targetRepoId) { + process.exit(1); + } + log(`Target repository ID: ${targetRepoId}`); + + // Fetch target categories + const targetCategories = await fetchCategories(targetOctokit, TARGET_ORG, TARGET_REPO); + if (targetCategories.length === 0) { + error("No categories found in target repository"); + process.exit(1); + } + + log("Available categories in target repository:"); + targetCategories.forEach(cat => { + log(` ${cat.name} (${cat.slug})`); + }); + + // Fetch target labels + const targetLabels = await fetchLabels(targetOctokit, TARGET_ORG, TARGET_REPO); + log(`Available labels in target repository: ${targetLabels.length} labels`); + + // Start processing discussions + log("\nStarting to fetch and copy discussions..."); + await processDiscussionsPage( + sourceOctokit, + targetOctokit, + SOURCE_ORG, + SOURCE_REPO, + targetRepoId, + targetCategories, + targetLabels, + null, + startFromNumber + ); + + // Summary + log("\n"); + log("=".repeat(60)); + log("Discussion copy completed!"); + log(`Total discussions found: ${totalDiscussions}`); + log(`Discussions created: ${createdDiscussions}`); + log(`Discussions skipped: ${skippedDiscussions}`); + log(`Total comments found: ${totalComments}`); + log(`Comments copied: ${copiedComments}`); + log(`Primary rate limits hit: ${primaryRateLimitHits}`); + log(`Secondary rate limits hit: ${secondaryRateLimitHits}`); + + if (missingCategories.length > 0) { + warn("\nThe following categories were missing and need to be created manually:"); + missingCategories.forEach(cat => { + warn(` - ${cat}`); + }); + warn(""); + warn("To create categories manually:"); + warn(`1. Go to https://github.com/${TARGET_ORG}/${TARGET_REPO}/discussions`); + warn("2. Click 'New discussion'"); + warn("3. Look for category management options"); + warn("4. Create the missing categories with appropriate names and descriptions"); + } + + if (skippedDiscussions > 0) { + warn("\nSome discussions were skipped. Please check the categories in the target repository."); + } + + log("\nAll done! ✨"); + + } catch (err) { + error(`Fatal error: ${err.message}`); + if (err.stack) { + console.error(err.stack); + } + process.exit(1); + } +} + +// Run main function +main(); diff --git a/scripts/migrate-discussions/package-lock.json b/scripts/migrate-discussions/package-lock.json new file mode 100644 index 0000000..9a9381b --- /dev/null +++ b/scripts/migrate-discussions/package-lock.json @@ -0,0 +1,726 @@ +{ + "name": "migrate-discussions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "migrate-discussions", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "octokit": "^3.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@octokit/app": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/app/-/app-14.1.0.tgz", + "integrity": "sha512-g3uEsGOQCBl1+W1rgfwoRFUIR6PtvB2T1E4RpygeUU5LrLvlOqcxrt5lfykIeRpUPpupreGJUYl70fqMDXdTpw==", + "license": "MIT", + "dependencies": { + "@octokit/auth-app": "^6.0.0", + "@octokit/auth-unauthenticated": "^5.0.0", + "@octokit/core": "^5.0.0", + "@octokit/oauth-app": "^6.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/types": "^12.0.0", + "@octokit/webhooks": "^12.0.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/app/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "license": "MIT" + }, + "node_modules/@octokit/app/node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", + "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/app/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/auth-app": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.1.4.tgz", + "integrity": "sha512-QkXkSOHZK4dA5oUqY5Dk3S+5pN2s1igPjEASNQV8/vgJgW034fQWR16u7VsNOK/EljA00eyjYF5mWNxWKWhHRQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^7.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "deprecation": "^2.3.1", + "lru-cache": "npm:@wolfy1339/lru-cache@^11.0.2-patch.1", + "universal-github-app-jwt": "^1.1.2", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-7.1.0.tgz", + "integrity": "sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "@types/btoa-lite": "^1.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-6.1.0.tgz", + "integrity": "sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-4.1.0.tgz", + "integrity": "sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-unauthenticated": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-5.0.1.tgz", + "integrity": "sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "license": "MIT" + }, + "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-app": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-6.1.0.tgz", + "integrity": "sha512-nIn/8eUJ/BKUVzxUXd5vpzl1rwaVxMyYbQkNZjHrF7Vk/yu98/YDF/N2KeWO7uZ0g3b5EyiFXFkZI8rJ+DH1/g==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^7.0.0", + "@octokit/auth-oauth-user": "^4.0.0", + "@octokit/auth-unauthenticated": "^5.0.0", + "@octokit/core": "^5.0.0", + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/oauth-methods": "^4.0.0", + "@types/aws-lambda": "^8.10.83", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz", + "integrity": "sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-4.1.0.tgz", + "integrity": "sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-graphql": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-4.0.1.tgz", + "integrity": "sha512-R8ZQNmrIKKpHWC6V2gum4x9LG2qF1RxRjo27gjQcG3j+vf2tLsEfE7I/wRWEPzYMaenr1M+qDAtNcwZve1ce1A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.1.0.tgz", + "integrity": "sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^13.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" + } + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-12.3.2.tgz", + "integrity": "sha512-exj1MzVXoP7xnAcAB3jZ97pTvVPkQF9y6GA/dvYC47HV7vLv+24XRS6b/v/XnyikpEuvMhugEXdGtAlU086WkQ==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/webhooks-methods": "^4.1.0", + "@octokit/webhooks-types": "7.6.1", + "aggregate-error": "^3.1.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-4.1.0.tgz", + "integrity": "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz", + "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", + "license": "MIT" + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.153", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.153.tgz", + "integrity": "sha512-j5zuETAQtPKuU8ZeqtcLdqLxQeNffX1Dd1Sr3tP56rYZD21Ph49iIqWbiHHqwLXugsMPSsgX/bAZI29Patlbbw==", + "license": "MIT" + }, + "node_modules/@types/btoa-lite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", + "integrity": "sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", + "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.13.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "license": "MIT" + }, + "node_modules/btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "license": "ISC" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "name": "@wolfy1339/lru-cache", + "version": "11.0.2-patch.1", + "resolved": "https://registry.npmjs.org/@wolfy1339/lru-cache/-/lru-cache-11.0.2-patch.1.tgz", + "integrity": "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==", + "license": "ISC", + "engines": { + "node": "18 >=18.20 || 20 || >=22" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/octokit": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/octokit/-/octokit-3.2.2.tgz", + "integrity": "sha512-7Abo3nADdja8l/aglU6Y3lpnHSfv0tw7gFPiqzry/yCU+2gTAX7R1roJ8hJrxIK+S1j+7iqRJXtmuHJ/UDsBhQ==", + "license": "MIT", + "dependencies": { + "@octokit/app": "^14.0.2", + "@octokit/core": "^5.0.0", + "@octokit/oauth-app": "^6.0.0", + "@octokit/plugin-paginate-graphql": "^4.0.0", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1", + "@octokit/plugin-retry": "^6.0.0", + "@octokit/plugin-throttling": "^8.0.0", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^13.0.0", + "@octokit/webhooks": "^12.3.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/undici-types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "license": "MIT" + }, + "node_modules/universal-github-app-jwt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.2.0.tgz", + "integrity": "sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.0", + "jsonwebtoken": "^9.0.2" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/scripts/migrate-discussions/package.json b/scripts/migrate-discussions/package.json new file mode 100644 index 0000000..68f8b6d --- /dev/null +++ b/scripts/migrate-discussions/package.json @@ -0,0 +1,37 @@ +{ + "name": "migrate-discussions", + "version": "1.0.0", + "description": "Migrate GitHub Discussions between repositories, including categories, labels, comments, and replies. Supports cross-enterprise and cross-instance migrations with automatic rate limit handling.", + "main": "migrate-discussions.js", + "scripts": { + "start": "node migrate-discussions.js", + "help": "node migrate-discussions.js --help" + }, + "keywords": [ + "github", + "discussions", + "migrate", + "copy", + "github-api", + "graphql", + "octokit", + "github-enterprise" + ], + "author": "Josh Johanning", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "dependencies": { + "octokit": "^3.1.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/joshjohanning/github-misc-scripts.git", + "directory": "scripts/migrate-discussions" + }, + "bugs": { + "url": "https://github.com/joshjohanning/github-misc-scripts/issues" + }, + "homepage": "https://github.com/joshjohanning/github-misc-scripts/tree/main/scripts/migrate-discussions" +}