From d049eee4e61e82709947a9a86fae4e830ce59311 Mon Sep 17 00:00:00 2001 From: Andrew Hammond <445764+ahammond@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:29:30 -0700 Subject: [PATCH 1/4] feat: throttle re-runs [INFRA-77654] --- script.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/script.js b/script.js index a760575..00a0a01 100644 --- a/script.js +++ b/script.js @@ -295,7 +295,15 @@ export async function script( return; } - // Don't re-run more than once every 30 min? + // Don't re-run more than once every 30 min + if (lastRun.run_started_at) { + const lastRunTime = Date.parse(lastRun.run_started_at); + const minutesSinceLastRun = (Date.now() - lastRunTime) / (1000 * 60); + if (minutesSinceLastRun < 30) { + octokit.log.info(`${repository.full_name} workflow ran ${minutesSinceLastRun.toFixed(1)} minutes ago, skipping re-run (throttled)`); + return; + } + } // Otherwise trigger a re-run octokit.log.info(`${repository.full_name} Triggering re-run of ${lastRun.id}`); From d672e327d62d854f7788dc6e17b49101a7734606 Mon Sep 17 00:00:00 2001 From: Andrew Hammond <445764+ahammond@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:23:36 -0700 Subject: [PATCH 2/4] pnpm defaults --- CLAUDE.md | 100 ++++ OCTOHERD.md | 1499 +++++++++++++++++++++++++++++++++++++++++++++++++++ script.js | 119 ++++ 3 files changed, 1718 insertions(+) create mode 100644 CLAUDE.md create mode 100644 OCTOHERD.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..097124a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is an Octoherd script that automates the merging of Renovate dependency update PRs across multiple repositories. It's designed to handle major version library updates, "all non-major updates" PRs, and projen update PRs. + +## Key Commands + +### Running the Script +```bash +# Basic usage +node cli.js \ + -R time-loop/*-cdk \ + -T ghp_TOKEN \ + --majorVersion v11 + +# Handle "all non-major updates" PRs +node cli.js \ + -R time-loop/*-cdk \ + -T ghp_TOKEN \ + --majorVersion all + +# Handle projen update PRs +node cli.js \ + -R time-loop/*-cdk \ + -T ghp_TOKEN \ + --majorVersion projen +``` + +### Testing +```bash +npm test # Alias for: node script.js +npm start # Alias for: node cli.js +``` + +## Architecture + +### Core Files +- **cli.js** - Entry point that imports and runs the script using `@octoherd/cli/run` +- **script.js** - Main script logic exported as `async function script(octokit, repository, options)` + +### Workflow Logic (script.js) + +The script follows this sequence for each repository: + +1. **Safety checks** (lines 46-61): + - Skip archived repositories + - Check for `octoherd-no-touch` topic to skip protected repos + +2. **PR Discovery** (lines 63-100): + - Search for PRs matching the expected title pattern + - For special cases (`all` and `projen`), check `maxAgeDays` to avoid stale merged PRs + - Skip if PR is already merged, closed, or in draft state + +3. **PR Validation** (lines 104-191): + - Uses GraphQL query to fetch comprehensive PR status + - Checks: mergeable state, review decision, CI status (statusCheckRollup) + - Exits early if PR cannot be updated, CI failing, or conflicts exist + +4. **Auto-approval** (lines 193-239): + - Attempts to approve PR if not already approved and viewer can approve + - Re-checks approval status after attempting approval + +5. **Merge** (lines 241-253): + - Squash merges the PR with standardized commit title format + +6. **Workflow Re-run** (lines 256-305): + - If no PR exists, finds the relevant workflow (renovate.yml or update-projen-main.yml) + - Checks if workflow is already running + - Validates that at least 30 minutes have passed since the last workflow run (throttling) + - Re-runs the last workflow if it's not currently active and throttle period has elapsed + - Throttling prevents excessive CI usage when the script runs frequently + +### Special Cases + +The script handles three distinct patterns via the `--majorVersion` parameter: + +- **Standard major version** (e.g., `v11`): Targets PRs like "fix(deps): update dependency @time-loop/cdk-library to v11" +- **`all`**: Targets "fix(deps): update all non-major dependencies" PRs, uses `maxAgeDays` parameter +- **`projen`**: Targets "fix(deps): upgrade projen" PRs from `update-projen-main` workflow, uses `maxAgeDays` parameter + +### Options + +- `--majorVersion` (required): Major version (e.g., `v11`), `all`, or `projen` +- `--library` (default: `@time-loop/cdk-library`): Library name (ignored for `all` and `projen`) +- `--maxAgeDays` (default: 7): Maximum age in days for merged PRs (only used with `all` and `projen`) + +### PAT Requirements + +The GitHub Personal Access Token needs: +- `repo` - Full control of private repositories + +## Key Implementation Details + +- Uses `@octoherd/cli` framework for multi-repository operations +- Combines REST API and GraphQL for comprehensive PR status checking +- Workflow re-run logic ensures Renovate runs are triggered when PRs don't exist +- The `noTouchTopicName` constant (`octoherd-no-touch`) provides escape hatch for repositories diff --git a/OCTOHERD.md b/OCTOHERD.md new file mode 100644 index 0000000..cafad1d --- /dev/null +++ b/OCTOHERD.md @@ -0,0 +1,1499 @@ +# Octoherd Framework Reference for LLMs + +**Target Audience**: Language Learning Models (LLMs) working with Octoherd-based scripts + +**Last Updated**: 2025-10-22 + +**Official Documentation**: https://github.com/octoherd/cli + +## Table of Contents + +1. [Overview](#overview) +2. [Core Architecture](#core-architecture) +3. [Script Function Signature](#script-function-signature) +4. [The Octokit Instance](#the-octokit-instance) +5. [The Repository Object](#the-repository-object) +6. [CLI Options and Execution](#cli-options-and-execution) +7. [Repository Matching Patterns](#repository-matching-patterns) +8. [Logging Patterns](#logging-patterns) +9. [Error Handling](#error-handling) +10. [Pagination](#pagination) +11. [Common Patterns and Best Practices](#common-patterns-and-best-practices) +12. [TypeScript Type Definitions](#typescript-type-definitions) +13. [Complete Examples](#complete-examples) + +--- + +## Overview + +### What is Octoherd? + +Octoherd is a CLI framework for running custom JavaScript scripts across multiple GitHub repositories. It solves the problem of managing bulk operations across many repositories in an organization by providing: + +- Consistent script execution model +- Pre-authenticated GitHub API access via Octokit +- Repository filtering and matching capabilities +- Built-in logging, caching, and debugging support +- Error handling and retry mechanisms + +### Design Philosophy + +- **Script-first approach**: Write custom logic rather than relying on pre-built commands +- **Simplicity**: Minimal boilerplate, focus on the task at hand +- **Flexibility**: Handle any GitHub API operation across any set of repositories +- **Safety**: Built-in confirmation prompts, dry-run support, and error recovery + +### Common Use Cases + +- Synchronizing branch protection rules +- Managing repository settings at scale +- Automating dependency updates across multiple repos +- Bulk label management +- Repository cleanup and archival tasks +- Custom automation workflows + +--- + +## Core Architecture + +### Package Structure + +An Octoherd script typically consists of: + +``` +my-octoherd-script/ +├── package.json # Dependencies and metadata +├── cli.js # Entry point for CLI execution +├── script.js # Main script logic (exported script function) +└── README.md # Documentation +``` + +### package.json Configuration + +```json +{ + "name": "@org/octoherd-script-my-script", + "version": "1.0.0", + "type": "module", // REQUIRED: Must be ES Module + "exports": "./script.js", + "bin": { + "octoherd-script-my-script": "./cli.js" + }, + "keywords": ["octoherd-script"], // Recommended for discoverability + "dependencies": { + "@octoherd/cli": "^4.0.5" + }, + "engines": { + "node": ">= 18.17.1" + } +} +``` + +### cli.js Entry Point + +```javascript +#!/usr/bin/env node + +import { script } from "./script.js"; +import { run } from "@octoherd/cli/run"; + +run(script); +``` + +**Key Points**: +- Must have shebang `#!/usr/bin/env node` +- Must import `script` from your script module +- Must import `run` from `@octoherd/cli/run` +- Call `run(script)` to execute + +--- + +## Script Function Signature + +### Basic Structure + +Every Octoherd script must export an async function with this exact signature: + +```javascript +/** + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {object} options + */ +export async function script(octokit, repository, options) { + // Your script logic here +} +``` + +### Parameters Explained + +1. **`octokit`** (type: `import('@octoherd/cli').Octokit`) + - Pre-authenticated Octokit instance + - Includes custom logging methods + - Has built-in plugins: pagination, retry, throttling + +2. **`repository`** (type: `import('@octoherd/cli').Repository`) + - The response data from GitHub's `GET /repos/{owner}/{repo}` API + - Contains all repository metadata (name, owner, settings, etc.) + +3. **`options`** (type: `object`) + - All CLI flags/options that are not consumed by Octoherd itself + - Define custom options in JSDoc for your script + +### Defining Custom Options + +```javascript +/** + * My custom script description + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {object} options + * @param {string} [options.majorVersion] - Major version number (e.g., v11) + * @param {string} [options.library] - Full library name (e.g., @org/library) + * @param {number} [options.maxAgeDays=7] - Maximum age in days + * @param {boolean} [options.dryRun=false] - Preview changes without applying + */ +export async function script( + octokit, + repository, + { majorVersion, library = '@org/default', maxAgeDays = 7, dryRun = false } +) { + // Validate required options + if (!majorVersion) { + throw new Error('--majorVersion is required, example: v11'); + } + + // Use options in your logic + octokit.log.info(`Processing ${repository.full_name} for ${library} ${majorVersion}`); +} +``` + +--- + +## The Octokit Instance + +### What is Provided + +The `octokit` parameter is a customized instance of `@octoherd/octokit`, which extends the standard Octokit with: + +1. **Pre-authentication**: Token is already configured +2. **Custom logging**: `octokit.log.*` methods +3. **Built-in plugins**: + - `@octokit/plugin-paginate-rest`: For paginating REST API results + - `@octokit/plugin-retry`: Automatic retry on transient errors + - `@octokit/plugin-throttling`: Rate limit management + +### Making REST API Calls + +```javascript +// Simple request +const response = await octokit.request('GET /repos/{owner}/{repo}/topics', { + owner: 'octoherd', + repo: 'cli' +}); + +// Using destructuring +const { data } = await octokit.request('GET /repos/{owner}/{repo}/pulls', { + owner: repository.owner.login, + repo: repository.name, + state: 'open' +}); + +// POST request +await octokit.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews', { + owner: repository.owner.login, + repo: repository.name, + pull_number: 123, + event: 'APPROVE', + commit_id: 'abc123' +}); + +// PUT request +await octokit.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', { + owner: repository.owner.login, + repo: repository.name, + pull_number: 123, + commit_title: 'Merge PR #123', + merge_method: 'squash' +}); +``` + +### Making GraphQL Queries + +```javascript +// Basic GraphQL query +const result = await octokit.graphql( + `query prStatus($htmlUrl: URI!) { + resource(url: $htmlUrl) { + ... on PullRequest { + mergeable + reviewDecision + viewerCanUpdate + commits(last: 1) { + nodes { + commit { + oid + statusCheckRollup { + state + } + } + } + } + } + } + }`, + { + htmlUrl: 'https://github.com/owner/repo/pull/123' + } +); + +// Access the data +const { reviewDecision, mergeable } = result.resource; +const combinedStatus = result.resource.commits.nodes[0].commit.statusCheckRollup.state; +``` + +**GraphQL Best Practices**: +- Use variables instead of template literals to prevent injection attacks +- Only request the fields you need +- Be aware of rate limits when making many queries +- Consider nested pagination limitations (only single resource pagination supported) + +### REST API Pagination + +The `octokit.paginate()` method automatically handles pagination: + +```javascript +// Get ALL pull requests (automatically paginated) +const allPRs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { + owner: repository.owner.login, + repo: repository.name, + state: 'all', + per_page: 100 // Max 100, default 30 + }, + (response) => response.data // Map function to extract data +); + +// Process all results +for (const pr of allPRs) { + console.log(pr.title); +} +``` + +**Pagination Best Practices**: +- Set `per_page: 100` for efficiency (max allowed) +- Use map function to extract only needed data (reduces memory) +- Consider early termination with `done()` callback: + +```javascript +// Stop pagination early +const recentPRs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { owner, repo, state: 'all', per_page: 100 }, + (response, done) => { + const data = response.data; + // Stop if we find a PR older than 30 days + if (data.some(pr => isOlderThan(pr, 30))) { + done(); + } + return data; + } +); +``` + +--- + +## The Repository Object + +### Structure + +The `repository` parameter contains the complete response from GitHub's `GET /repos/{owner}/{repo}` REST API endpoint. + +### Key Properties + +```javascript +// Repository identification +repository.id // 12345678 +repository.node_id // "MDEwOlJlcG9zaXRvcnkxMjM0NTY3OA==" +repository.name // "my-repo" +repository.full_name // "owner/my-repo" +repository.owner.login // "owner" +repository.owner.id // 87654321 + +// Repository settings +repository.private // false +repository.archived // false +repository.disabled // false +repository.is_template // false +repository.fork // false + +// Repository features +repository.has_issues // true +repository.has_projects // true +repository.has_wiki // true +repository.has_pages // false +repository.has_downloads // true +repository.has_discussions // false + +// URLs +repository.html_url // "https://github.com/owner/my-repo" +repository.url // API URL +repository.git_url // Git protocol URL +repository.ssh_url // SSH clone URL +repository.clone_url // HTTPS clone URL + +// Metadata +repository.description // "Repository description" +repository.homepage // "https://example.com" +repository.language // "JavaScript" +repository.default_branch // "main" + +// Statistics +repository.size // 1234 (KB) +repository.stargazers_count // 100 +repository.watchers_count // 50 +repository.forks_count // 25 +repository.open_issues_count // 10 + +// Timestamps (ISO 8601 format) +repository.created_at // "2020-01-01T00:00:00Z" +repository.updated_at // "2025-10-22T12:34:56Z" +repository.pushed_at // "2025-10-22T12:00:00Z" + +// Permissions (based on the authenticated user) +repository.permissions // { admin: true, maintain: true, push: true, triage: true, pull: true } +``` + +### Common Patterns + +```javascript +// Extracting owner and repo name +const [repoOwner, repoName] = repository.full_name.split('/'); + +// Creating base parameters for API calls +const baseParams = { + owner: repository.owner.login, + repo: repository.name +}; + +// Checking repository state +if (repository.archived) { + octokit.log.info(`${repository.full_name} is archived, skipping.`); + return; +} + +if (repository.disabled) { + octokit.log.warn(`${repository.full_name} is disabled`); + return; +} + +// Working with repository topics (requires separate API call) +const topics = await octokit.request('GET /repos/{owner}/{repo}/topics', { + owner: repository.owner.login, + repo: repository.name +}); + +if (topics.data.names.includes('no-automation')) { + octokit.log.info(`${repository.full_name} has no-automation topic, skipping`); + return; +} +``` + +--- + +## CLI Options and Execution + +### Required Options + +```bash +# Minimum required: script path +octoherd run -S path/to/script.js + +# With token and repositories +octoherd run -S path/to/script.js \ + -T ghp_your_github_token_here \ + -R owner/repo +``` + +### All CLI Options + +| Flag | Long Form | Description | Type | Default | +|------|-----------|-------------|------|---------| +| `-S` | `--octoherd-script` | Path to JavaScript script (ES Module) | string | (required) | +| `-T` | `--octoherd-token` | GitHub Personal Access Token | string | (prompts) | +| `-R` | `--octoherd-repos` | Target repositories | string[] | (prompts) | +| | `--octoherd-cache` | Cache responses for debugging | boolean/string | false | +| | `--octoherd-debug` | Show debug logs | boolean | false | +| | `--octoherd-bypass-confirms` | Skip confirmation prompts | boolean | false | +| | `--octoherd-base-url` | GitHub Enterprise Server API URL | string | https://api.github.com | + +### Token Requirements + +**For public repositories**: `public_repo` scope + +**For private repositories**: `repo` scope (full control) + +**Creating a token**: +1. Go to https://github.com/settings/tokens +2. Generate new token (classic) +3. Select appropriate scopes +4. Copy token (it won't be shown again) + +### Usage Examples + +```bash +# Basic usage with prompts +octoherd run -S ./script.js + +# Pass token and repos as flags +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R owner/repo + +# All repositories for an owner +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' + +# Multiple repository patterns +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + -R 'other-owner/specific-repo' + +# Exclude specific repositories +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + -R '!owner/excluded-repo' + +# Skip confirmations (for CI/automation) +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + --octoherd-bypass-confirms + +# Enable debugging +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + --octoherd-debug + +# Cache responses (creates ./cache folder) +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + --octoherd-cache + +# GitHub Enterprise Server +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + --octoherd-base-url https://github.company.com/api/v3 + +# Custom script options +octoherd run -S ./script.js \ + -T $GITHUB_TOKEN \ + -R 'owner/*' \ + --majorVersion v11 \ + --library @org/package \ + --dryRun +``` + +--- + +## Repository Matching Patterns + +### Pattern Syntax + +| Pattern | Matches | Example | +|---------|---------|---------| +| `owner/repo` | Single specific repository | `octoherd/cli` | +| `owner/*` | All repositories for an owner | `octoherd/*` | +| `*` | All accessible repositories | `*` | +| `!owner/repo` | Exclude specific repository | `!octoherd/test-repo` | + +### Combining Patterns + +```bash +# Include all from owner1, plus specific repo from owner2 +-R 'owner1/*' -R 'owner2/specific-repo' + +# Include all from owner, but exclude specific ones +-R 'owner/*' -R '!owner/old-repo' -R '!owner/archived-repo' + +# Complex filtering +-R 'org1/*' -R 'org2/*' -R '!org1/exclude1' -R '!org2/exclude2' +``` + +### Access Control + +- Octoherd only processes repositories the authenticated user has access to +- Private repositories require appropriate token scopes +- Organization repositories require organization membership +- Token permissions determine what operations can be performed + +--- + +## Logging Patterns + +### Available Methods + +The `octokit.log` object provides structured logging: + +```javascript +octokit.log.info(message, ...args) // Informational messages +octokit.log.warn(message, ...args) // Warnings +octokit.log.error(message, ...args) // Errors +octokit.log.debug(message, ...args) // Debug (only when --octoherd-debug) +``` + +### Basic Logging + +```javascript +// Simple message +octokit.log.info(`Processing ${repository.full_name}`); + +// String interpolation (printf-style) +octokit.log.info('Processing %s', repository.full_name); + +// Multiple values +octokit.log.info('Found %d PRs in %s', prs.length, repository.full_name); +``` + +### Structured Logging with Metadata + +```javascript +// Log with structured data +const logData = { + pr: { + number: pr.number, + reviewDecision: 'APPROVED', + mergeable: 'MERGEABLE', + combinedStatus: 'SUCCESS' + } +}; + +octokit.log.info( + logData, + '%s: ready to merge', + pr.html_url +); + +// This outputs both the human-readable message and structured data +// for programmatic consumption +``` + +### Logging Best Practices + +```javascript +// 1. Always include repository context +octokit.log.info(`${repository.full_name}: operation completed`); + +// 2. Use appropriate log levels +if (repository.archived) { + octokit.log.info(`${repository.full_name} is archived, skipping.`); + return; +} + +if (someWarningCondition) { + octokit.log.warn(`${repository.full_name} has unusual configuration`); +} + +// 3. Include URLs for easy navigation +octokit.log.info('Pull request merged: %s', pr.html_url); + +// 4. Log both success and skip conditions +octokit.log.info(`${repository.full_name} already merged ${pr.html_url} at ${pr.merged_at}`); +return; + +// 5. Debug logging for detailed information +octokit.log.debug('API response: %j', response.data); +``` + +### Common Logging Patterns + +```javascript +// Operation start +octokit.log.info(`${repository.full_name}: Starting operation`); + +// Skipping with reason +octokit.log.info(`${repository.full_name} is archived, skipping.`); +return; + +// Warning about state +octokit.log.warn(`${repository.full_name} has DRAFT PR at ${html_url}`); + +// Success with details +octokit.log.info('Pull request merged: %s', pr.html_url); + +// Error (caught in try-catch) +octokit.log.error(e); + +// Not found condition +octokit.log.warn(`${repository.full_name} has no PR for ${expectedTitle}`); +``` + +--- + +## Error Handling + +### Basic Pattern + +Always wrap your script logic in try-catch: + +```javascript +export async function script(octokit, repository, options) { + try { + // Your script logic here + + } catch (e) { + octokit.log.error(e); + // Optionally re-throw for critical errors + // throw e; + } +} +``` + +### Handling GitHub API Errors + +```javascript +try { + const response = await octokit.request('GET /repos/{owner}/{repo}/topics', { + owner: repository.owner.login, + repo: repository.name + }); +} catch (error) { + // GitHub API errors have a status property + if (error.status === 404) { + octokit.log.warn(`${repository.full_name}: topics not found`); + return; + } + + if (error.status === 403) { + octokit.log.error(`${repository.full_name}: forbidden (check permissions)`); + return; + } + + // Unknown error, log and potentially re-throw + octokit.log.error(error); + throw error; +} +``` + +### Built-in Error Recovery + +The `@octoherd/octokit` package includes automatic retry and throttling: + +**Retry Plugin**: +- Automatically retries on transient server errors (5xx) +- Uses exponential backoff +- Handles rate limit errors + +**Throttling Plugin**: +- Prevents hitting rate limits +- Automatically waits when approaching limits +- Handles abuse detection warnings + +### Error Handling Best Practices + +```javascript +// 1. Fail gracefully for missing resources +const workflows = await octokit.paginate( + 'GET /repos/{owner}/{repo}/actions/workflows', + { owner, repo, per_page: 100 }, + (response) => response.data +); + +const targetWorkflow = workflows.find(w => w.path === '.github/workflows/renovate.yml'); +if (!targetWorkflow) { + octokit.log.error(`${repository.full_name}: workflow not found`); + return; // Don't throw, just skip this repo +} + +// 2. Validate preconditions early +if (!options.requiredOption) { + throw new Error('--requiredOption is required, example: value'); +} + +// 3. Handle permission issues gracefully +if (!repository.permissions?.push) { + octokit.log.warn(`${repository.full_name}: insufficient permissions`); + return; +} + +// 4. Provide context in error messages +try { + await octokit.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', { + owner, repo, pull_number: pr.number, merge_method: 'squash' + }); +} catch (error) { + octokit.log.error( + `${repository.full_name}: failed to merge PR #${pr.number}: ${error.message}` + ); +} +``` + +--- + +## Pagination + +### REST API Pagination + +#### Basic Pattern + +```javascript +// Paginate all results +const items = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { + owner: repository.owner.login, + repo: repository.name, + state: 'all', + per_page: 100 // Use max for efficiency + }, + (response) => response.data +); +``` + +#### Early Termination + +```javascript +// Stop pagination when a condition is met +const recentPRs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { owner, repo, state: 'all', per_page: 100 }, + (response, done) => { + const data = response.data; + + // Check if we should stop + const oldestPR = data[data.length - 1]; + if (isOlderThan(oldestPR.created_at, maxAgeDays)) { + done(); // Stop pagination + } + + return data; + } +); +``` + +#### Memory-Efficient Pagination + +```javascript +// Extract only needed fields to reduce memory usage +const prTitles = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { owner, repo, state: 'all', per_page: 100 }, + (response) => response.data.map(pr => ({ + number: pr.number, + title: pr.title, + html_url: pr.html_url + })) +); +``` + +#### Iterator Pattern + +For processing items as they arrive (useful for large datasets): + +```javascript +for await (const response of octokit.paginate.iterator( + 'GET /repos/{owner}/{repo}/pulls', + { owner, repo, state: 'all', per_page: 100 } +)) { + // Process each page as it arrives + for (const pr of response.data) { + console.log(pr.title); + } +} +``` + +### GraphQL Pagination + +GraphQL pagination in Octoherd requires manual implementation or using `@octokit/plugin-paginate-graphql`: + +```javascript +// Note: This requires additional setup beyond base @octoherd/octokit +// Manual cursor-based pagination +let hasNextPage = true; +let cursor = null; + +while (hasNextPage) { + const result = await octokit.graphql( + `query ($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, after: $cursor) { + nodes { + number + title + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`, + { + owner: repository.owner.login, + repo: repository.name, + cursor + } + ); + + // Process results + const prs = result.repository.pullRequests.nodes; + for (const pr of prs) { + console.log(pr.title); + } + + // Update pagination + hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage; + cursor = result.repository.pullRequests.pageInfo.endCursor; +} +``` + +### Pagination Best Practices + +1. **Always use `per_page: 100`** for REST API calls (maximum allowed) +2. **Use map function** to extract only needed data (reduces memory) +3. **Implement early termination** when you don't need all results +4. **Consider iterator pattern** for very large datasets +5. **Be aware of rate limits** - pagination counts against your quota +6. **Use GraphQL** when you need complex related data (fewer requests) + +--- + +## Common Patterns and Best Practices + +### Repository Filtering + +```javascript +// Skip archived repositories +if (repository.archived) { + octokit.log.info(`${repository.full_name} is archived, skipping.`); + return; +} + +// Skip disabled repositories +if (repository.disabled) { + octokit.log.warn(`${repository.full_name} is disabled, skipping.`); + return; +} + +// Check repository topics for opt-out +const topics = await octokit.request('GET /repos/{owner}/{repo}/topics', { + owner: repository.owner.login, + repo: repository.name +}); + +if (topics.data.names.includes('octoherd-no-touch')) { + octokit.log.warn(`${repository.full_name} has octoherd-no-touch topic, skipping.`); + return; +} + +// Check permissions +if (!repository.permissions?.push) { + octokit.log.warn(`${repository.full_name}: no push permission, skipping.`); + return; +} +``` + +### Finding and Processing Pull Requests + +```javascript +// Get all PRs (or filter by state) +const prs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { + owner: repository.owner.login, + repo: repository.name, + state: 'all', // 'open', 'closed', 'all' + per_page: 100 + }, + (response) => response.data +); + +// Find specific PR by title +const targetPR = prs.find(pr => pr.title.startsWith('fix(deps): update dependency')); + +if (!targetPR) { + octokit.log.warn(`${repository.full_name}: PR not found`); + return; +} + +// Check PR state +if (targetPR.merged_at) { + octokit.log.info(`${repository.full_name}: already merged at ${targetPR.merged_at}`); + return; +} + +if (targetPR.closed_at) { + octokit.log.info(`${repository.full_name}: PR was closed without merging`); + return; +} + +if (targetPR.draft) { + octokit.log.warn(`${repository.full_name}: PR is still a draft`); + return; +} +``` + +### Using GraphQL for Rich PR Data + +```javascript +// Get PR status using GraphQL (more efficient than multiple REST calls) +const result = await octokit.graphql( + `query prStatus($htmlUrl: URI!) { + resource(url: $htmlUrl) { + ... on PullRequest { + mergeable + reviewDecision + viewerCanUpdate + viewerDidAuthor + latestOpinionatedReviews(first: 10, writersOnly: true) { + nodes { + viewerDidAuthor + } + } + commits(last: 1) { + nodes { + commit { + oid + statusCheckRollup { + state + } + } + } + } + } + } + }`, + { htmlUrl: pr.html_url } +); + +const { reviewDecision, mergeable, viewerCanUpdate } = result.resource; +const combinedStatus = result.resource.commits.nodes[0].commit.statusCheckRollup.state; +const latestCommitId = result.resource.commits.nodes[0].commit.oid; +``` + +### Approving Pull Requests + +```javascript +// Check if approval is needed +if (reviewDecision !== 'APPROVED') { + // Only approve if we haven't already + if (!viewerDidAuthor && !viewerDidApprove) { + await octokit.request( + 'POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews', + { + owner: repository.owner.login, + repo: repository.name, + pull_number: pr.number, + event: 'APPROVE', + commit_id: latestCommitId + } + ); + + octokit.log.info(`${repository.full_name}: approved PR #${pr.number}`); + } +} +``` + +### Merging Pull Requests + +```javascript +// Verify PR is ready to merge +if (combinedStatus !== 'SUCCESS') { + octokit.log.info(`${repository.full_name}: status is ${combinedStatus}, skipping`); + return; +} + +if (mergeable !== 'MERGEABLE') { + octokit.log.info(`${repository.full_name}: not mergeable, skipping`); + return; +} + +if (reviewDecision !== 'APPROVED') { + octokit.log.info(`${repository.full_name}: awaiting approval, skipping`); + return; +} + +// Merge the PR +const commit_title = `${pr.title} (#${pr.number})`; +await octokit.request( + 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', + { + owner: repository.owner.login, + repo: repository.name, + pull_number: pr.number, + commit_title, + merge_method: 'squash' // 'merge', 'squash', or 'rebase' + } +); + +octokit.log.info(`${repository.full_name}: merged PR #${pr.number} - ${pr.html_url}`); +``` + +### Working with GitHub Actions Workflows + +```javascript +// Get all workflows +const workflows = await octokit.paginate( + 'GET /repos/{owner}/{repo}/actions/workflows', + { + owner: repository.owner.login, + repo: repository.name, + per_page: 100 + }, + (response) => response.data +); + +// Find specific workflow +const workflowPath = '.github/workflows/renovate.yml'; +const targetWorkflow = workflows.find(w => w.path === workflowPath); + +if (!targetWorkflow) { + octokit.log.error(`${repository.full_name}: workflow ${workflowPath} not found`); + return; +} + +// Get recent workflow runs +const runs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', + { + owner: repository.owner.login, + repo: repository.name, + workflow_id: targetWorkflow.id, + per_page: 100 + }, + (response) => response.data +); + +// Filter to main branch and sort by run number +const mainRuns = runs + .filter(r => r.head_branch === 'main') + .sort((a, b) => b.run_number - a.run_number); + +const latestRun = mainRuns[0]; + +// Check if workflow is currently running +const runningStatuses = ['in_progress', 'queued', 'requested', 'waiting', 'pending']; +if (runningStatuses.includes(latestRun.status)) { + octokit.log.info( + `${repository.full_name}: workflow is ${latestRun.status}: ${latestRun.html_url}` + ); + return; +} + +// Trigger workflow re-run +await octokit.request( + 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun', + { + owner: repository.owner.login, + repo: repository.name, + run_id: latestRun.id + } +); + +octokit.log.info(`${repository.full_name}: triggered workflow re-run`); +``` + +### Date/Time Handling + +```javascript +// Parse ISO 8601 timestamps +const mergedAt = new Date(pr.merged_at); +const now = new Date(); + +// Calculate age in days +const ageMs = now.getTime() - mergedAt.getTime(); +const ageDays = ageMs / (1000 * 60 * 60 * 24); + +// Check if too old +if (ageDays > maxAgeDays) { + octokit.log.info( + `${repository.full_name}: PR merged ${ageDays.toFixed(1)} days ago, skipping` + ); + return; +} + +// Format for display +octokit.log.info( + `${repository.full_name}: PR merged at ${pr.merged_at} (${ageDays.toFixed(1)} days ago)` +); +``` + +### Dry Run Pattern + +```javascript +export async function script(octokit, repository, { dryRun = false }) { + // ... determine what changes to make ... + + if (dryRun) { + octokit.log.info(`[DRY RUN] Would merge PR #${pr.number} in ${repository.full_name}`); + return; + } + + // Actually perform the operation + await octokit.request('PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', { + owner: repository.owner.login, + repo: repository.name, + pull_number: pr.number, + merge_method: 'squash' + }); + + octokit.log.info(`${repository.full_name}: merged PR #${pr.number}`); +} +``` + +--- + +## TypeScript Type Definitions + +### Using Types in JavaScript (JSDoc) + +```javascript +// @ts-check // Enable TypeScript checking in JavaScript + +/** + * Script description + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {object} options + * @param {string} [options.majorVersion] + * @param {string} [options.library] + * @param {number} [options.maxAgeDays] + */ +export async function script(octokit, repository, options) { + // TypeScript will now provide type checking and autocomplete +} +``` + +### Available Types + +From `@octoherd/cli`: +- `Octokit`: The customized Octokit instance type +- `Repository`: The repository object type + +### Extending Options Type + +```javascript +/** + * @typedef {object} ScriptOptions + * @property {string} majorVersion - Major version number + * @property {string} [library] - Library name + * @property {number} [maxAgeDays] - Maximum age in days + * @property {boolean} [dryRun] - Preview mode + */ + +/** + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {ScriptOptions} options + */ +export async function script(octokit, repository, options) { + // options is now fully typed +} +``` + +### Getting Response Types + +For GitHub API response types, use `@octokit/types`: + +```javascript +/** + * @type {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls']['response']['data']} + */ +let pullRequests; +``` + +--- + +## Complete Examples + +### Example 1: Simple Repository Lister + +```javascript +// @ts-check + +/** + * List all repositories and their star counts + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + */ +export async function script(octokit, repository) { + try { + octokit.log.info( + `${repository.full_name}: ${repository.stargazers_count} stars` + ); + } catch (e) { + octokit.log.error(e); + } +} +``` + +### Example 2: Find and Report Outdated Dependencies + +```javascript +// @ts-check + +/** + * Find repositories with outdated dependencies + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {object} options + * @param {string} options.dependency - Dependency name to check + */ +export async function script(octokit, repository, { dependency }) { + if (!dependency) { + throw new Error('--dependency is required'); + } + + try { + // Skip archived repos + if (repository.archived) { + octokit.log.info(`${repository.full_name} is archived, skipping.`); + return; + } + + // Get package.json + try { + const { data } = await octokit.request( + 'GET /repos/{owner}/{repo}/contents/{path}', + { + owner: repository.owner.login, + repo: repository.name, + path: 'package.json' + } + ); + + // Decode content + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + const packageJson = JSON.parse(content); + + // Check dependencies + const deps = { + ...packageJson.dependencies, + ...packageJson.devDependencies + }; + + if (deps[dependency]) { + octokit.log.info( + `${repository.full_name}: uses ${dependency}@${deps[dependency]}` + ); + } + } catch (error) { + if (error.status === 404) { + octokit.log.debug(`${repository.full_name}: no package.json`); + return; + } + throw error; + } + } catch (e) { + octokit.log.error(e); + } +} +``` + +### Example 3: Auto-Merge Renovate PRs (Complete Pattern) + +```javascript +// @ts-check + +/** + * Auto-merge approved Renovate PRs + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {import('@octoherd/cli').Repository} repository + * @param {object} options + * @param {string} options.library - Library name + * @param {string} options.version - Version to merge + * @param {boolean} [options.dryRun=false] - Preview without merging + */ +export async function script( + octokit, + repository, + { library, version, dryRun = false } +) { + if (!library || !version) { + throw new Error('--library and --version are required'); + } + + try { + // Skip archived repos + if (repository.archived) { + octokit.log.info(`${repository.full_name} is archived, skipping.`); + return; + } + + const expectedTitle = `fix(deps): update dependency ${library} to ${version}`; + + // Find PR + const prs = await octokit.paginate( + 'GET /repos/{owner}/{repo}/pulls', + { + owner: repository.owner.login, + repo: repository.name, + state: 'open', + per_page: 100 + }, + (response) => response.data + ); + + const pr = prs.find(p => p.title === expectedTitle); + + if (!pr) { + octokit.log.debug(`${repository.full_name}: no matching PR found`); + return; + } + + if (pr.draft) { + octokit.log.warn(`${repository.full_name}: PR #${pr.number} is a draft`); + return; + } + + // Get PR details via GraphQL + const result = await octokit.graphql( + `query prStatus($htmlUrl: URI!) { + resource(url: $htmlUrl) { + ... on PullRequest { + mergeable + reviewDecision + viewerCanUpdate + commits(last: 1) { + nodes { + commit { + oid + statusCheckRollup { + state + } + } + } + } + } + } + }`, + { htmlUrl: pr.html_url } + ); + + const { reviewDecision, mergeable, viewerCanUpdate } = result.resource; + const combinedStatus = result.resource.commits.nodes[0].commit.statusCheckRollup.state; + + // Check merge conditions + if (!viewerCanUpdate) { + octokit.log.warn(`${repository.full_name}: cannot update PR #${pr.number}`); + return; + } + + if (combinedStatus !== 'SUCCESS') { + octokit.log.info( + `${repository.full_name}: PR #${pr.number} status is ${combinedStatus}` + ); + return; + } + + if (mergeable !== 'MERGEABLE') { + octokit.log.info( + `${repository.full_name}: PR #${pr.number} is not mergeable` + ); + return; + } + + if (reviewDecision !== 'APPROVED') { + octokit.log.info( + `${repository.full_name}: PR #${pr.number} awaiting approval` + ); + return; + } + + // Merge + if (dryRun) { + octokit.log.info( + `[DRY RUN] ${repository.full_name}: would merge PR #${pr.number} - ${pr.html_url}` + ); + return; + } + + const commit_title = `${pr.title} (#${pr.number})`; + await octokit.request( + 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', + { + owner: repository.owner.login, + repo: repository.name, + pull_number: pr.number, + commit_title, + merge_method: 'squash' + } + ); + + octokit.log.info( + `${repository.full_name}: merged PR #${pr.number} - ${pr.html_url}` + ); + } catch (e) { + octokit.log.error(e); + } +} +``` + +--- + +## Additional Resources + +### Official Documentation + +- **Octoherd CLI**: https://github.com/octoherd/cli +- **Octoherd Octokit**: https://github.com/octoherd/octokit +- **Example Scripts**: https://github.com/topics/octoherd-script +- **Awesome Scripts**: https://github.com/robvanderleek/awesome-octoherd-scripts + +### GitHub API Documentation + +- **REST API**: https://docs.github.com/en/rest +- **GraphQL API**: https://docs.github.com/en/graphql +- **Repository Object**: https://docs.github.com/en/rest/repos/repos#get-a-repository + +### Octokit Documentation + +- **Octokit.js**: https://github.com/octokit/octokit.js +- **REST Plugin**: https://github.com/octokit/plugin-rest.js +- **Paginate Plugin**: https://github.com/octokit/plugin-paginate-rest.js +- **Types**: https://github.com/octokit/types.ts + +### Community + +- **DEV Article**: https://dev.to/github/next-level-repository-management-with-octoherd-47ea +- **Octoherd Organization**: https://github.com/octoherd + +--- + +## Summary for LLMs + +When working with Octoherd scripts, remember: + +1. **Script structure**: Always export an async `script` function with three parameters: `octokit`, `repository`, `options` + +2. **Error handling**: Wrap logic in try-catch, log errors with `octokit.log.error(e)` + +3. **Early returns**: Check conditions early (archived, disabled, missing permissions) and return to skip + +4. **Logging**: Use appropriate log levels (info, warn, error, debug) and include repository context + +5. **Pagination**: Use `octokit.paginate()` with `per_page: 100` for efficiency + +6. **GraphQL**: Use for complex queries that would require multiple REST calls + +7. **Repository filtering**: Always check `repository.archived`, permissions, and other conditions + +8. **Type safety**: Use JSDoc comments with `@ts-check` for type checking in JavaScript + +9. **Custom options**: Document in JSDoc and provide defaults in function destructuring + +10. **Dry run**: Consider adding a `--dryRun` option for safe testing + +This framework is designed for bulk operations across repositories. Think in terms of: filter repositories, check conditions, perform action (or skip with reason), log outcome. diff --git a/script.js b/script.js index 00a0a01..1ad5d76 100644 --- a/script.js +++ b/script.js @@ -99,6 +99,125 @@ export async function script( return; } + // Apply .projenrc.ts fix for projen PRs + if (majorVersion === 'projen') { + try { + const prBranch = pr.head.ref; + const projenrcPath = '.projenrc.ts'; + + // Fetch the .projenrc.ts file from the PR branch + let fileResponse; + try { + fileResponse = await octokit.request( + 'GET /repos/{owner}/{repo}/contents/{path}', + { + ...baseParams, + path: projenrcPath, + ref: prBranch, + } + ); + } catch (error) { + // File doesn't exist or can't be fetched, continue normally + if (error.status === 404) { + octokit.log.info( + `${repository.full_name}: .projenrc.ts not found in PR branch, skipping fix` + ); + } else { + octokit.log.warn( + `${repository.full_name}: Could not fetch .projenrc.ts: ${error.message}` + ); + } + // Continue to PR validation + fileResponse = null; + } + + if (fileResponse) { + const content = Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'); + const fileSha = fileResponse.data.sha; + + // Check if the file contains the deprecated packageManager configuration + if (content.includes('packageManager: javascript.NodePackageManager.PNPM')) { + octokit.log.info( + `${repository.full_name}: Found deprecated packageManager configuration, applying fix...` + ); + + let updatedContent = content; + + // Remove the packageManager line + updatedContent = updatedContent.replace( + /^\s*packageManager:\s*javascript\.NodePackageManager\.PNPM,?\s*$/gm, + '' + ); + + // Remove the pnpmVersion line (handles any version) + updatedContent = updatedContent.replace( + /^\s*pnpmVersion:\s*['"][^'"]*['"],?\s*$/gm, + '' + ); + + // Check if 'javascript' import is still used elsewhere in the file + const remainingContent = updatedContent.replace( + /^import\s+\{[^}]*\}\s+from\s+['"]projen['"];?\s*$/gm, + '' + ); + + // If 'javascript' is not used anywhere else, remove the import + if (!remainingContent.includes('javascript.')) { + updatedContent = updatedContent.replace( + /^import\s+\{\s*javascript\s*\}\s+from\s+['"]projen['"];?[ \t]*\n/gm, + '' + ); + } + + // Clean up any extra blank lines that might have been created + updatedContent = updatedContent.replace(/\n\n\n+/g, '\n\n'); + + // Clean up blank lines left in object/array property lists after removing lines + // This handles: property,\n\n property -> property,\n property + updatedContent = updatedContent.replace(/,\s*\n\s*\n(\s+)/g, ',\n$1'); + + // Clean up blank lines at the start of objects/arrays after removing first property + // This handles: {\n\n property -> {\n property + updatedContent = updatedContent.replace(/\{\s*\n\s*\n(\s+)/g, '{\n$1'); + + // Clean up blank lines before closing braces + // This handles: \n\n} -> \n} while preserving commas + updatedContent = updatedContent.replace(/\s*\n\s*\n(\s*\})/g, '\n$1'); + + // Only commit if content actually changed + if (updatedContent !== content) { + // Commit the changes to the PR branch + await octokit.request( + 'PUT /repos/{owner}/{repo}/contents/{path}', + { + ...baseParams, + path: projenrcPath, + message: 'chore(projen): remove deprecated packageManager configuration', + content: Buffer.from(updatedContent).toString('base64'), + sha: fileSha, + branch: prBranch, + } + ); + + octokit.log.info( + `${repository.full_name}: Successfully fixed .projenrc.ts in PR ${html_url}` + ); + } else { + octokit.log.info( + `${repository.full_name}: No changes needed for .projenrc.ts` + ); + } + } + } + } catch (error) { + // Log error but don't stop the script + octokit.log.error( + `${repository.full_name}: Error while fixing .projenrc.ts: ${error.message}` + ); + // Continue to PR validation + } + } + // Copied from // https://github.com/gr2m/octoherd-script-merge-pull-requests/blob/main/script.js const result = await octokit.graphql( From 7988a9eb3333e8ec9f05c695d813e0e950e40186 Mon Sep 17 00:00:00 2001 From: Andrew Hammond <445764+ahammond@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:08:30 -0700 Subject: [PATCH 3/4] more support for getting to implicit pnpm version --- script.js | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/script.js b/script.js index 1ad5d76..b2ba4d1 100644 --- a/script.js +++ b/script.js @@ -2,6 +2,102 @@ const noTouchTopicName = 'octoherd-no-touch'; +/** + * Updates pnpm version from "9" to 9.15.7 in workflow files + * + * @param {import('@octoherd/cli').Octokit} octokit + * @param {object} baseParams - { owner, repo } + * @param {string} prBranch - PR branch name + * @param {string} repoFullName - Full repository name for logging + */ +async function updateWorkflowPnpmVersions(octokit, baseParams, prBranch, repoFullName) { + const workflowFiles = [ + '.github/workflows/build.yml', + '.github/workflows/release.yml', + '.github/workflows/update-projen-main.yml', + ]; + + const updatedFiles = []; + + for (const workflowPath of workflowFiles) { + try { + // Try to fetch the workflow file from the PR branch + let fileResponse; + try { + fileResponse = await octokit.request( + 'GET /repos/{owner}/{repo}/contents/{path}', + { + ...baseParams, + path: workflowPath, + ref: prBranch, + } + ); + } catch (error) { + if (error.status === 404) { + octokit.log.info( + `${repoFullName}: ${workflowPath} not found in PR branch, skipping` + ); + continue; + } + throw error; + } + + const content = Buffer.from(fileResponse.data.content, 'base64').toString('utf-8'); + const fileSha = fileResponse.data.sha; + + // Check if the file contains the pattern we're looking for + // We need to ensure we're updating the version under pnpm/action-setup + const pnpmActionRegex = /uses:\s*pnpm\/action-setup@v4[\s\S]*?with:\s*\n(\s+)version:\s*"9"/; + + if (!pnpmActionRegex.test(content)) { + // Pattern not found, skip this file + continue; + } + + // Replace quoted "9" with unquoted 9.15.7, preserving indentation + const updatedContent = content.replace( + /(\s+version:\s*)"9"/g, + '$19.15.7' + ); + + // Only commit if content actually changed + if (updatedContent !== content) { + await octokit.request( + 'PUT /repos/{owner}/{repo}/contents/{path}', + { + ...baseParams, + path: workflowPath, + message: 'chore(projen): update pnpm version in workflows', + content: Buffer.from(updatedContent).toString('base64'), + sha: fileSha, + branch: prBranch, + } + ); + + updatedFiles.push(workflowPath); + octokit.log.info( + `${repoFullName}: Updated pnpm version in ${workflowPath}` + ); + } + } catch (error) { + // Log warning but continue with other files + octokit.log.warn( + `${repoFullName}: Failed to update ${workflowPath}: ${error.message}` + ); + } + } + + if (updatedFiles.length > 0) { + octokit.log.info( + `${repoFullName}: Successfully updated pnpm version in ${updatedFiles.length} workflow file(s): ${updatedFiles.join(', ')}` + ); + } else { + octokit.log.info( + `${repoFullName}: No workflow files needed pnpm version update` + ); + } +} + /** * Drive renovate's major library update process. * @@ -143,6 +239,14 @@ export async function script( let updatedContent = content; + // Check if pnpmVersion exists and warn if it's not the default + const pnpmVersionMatch = content.match(/pnpmVersion:\s*['"]([^'"]+)['"]/); + if (pnpmVersionMatch && pnpmVersionMatch[1] !== '9') { + octokit.log.warn( + `${repository.full_name}: Removing non-standard pnpmVersion: '${pnpmVersionMatch[1]}'` + ); + } + // Remove the packageManager line updatedContent = updatedContent.replace( /^\s*packageManager:\s*javascript\.NodePackageManager\.PNPM,?\s*$/gm, @@ -208,6 +312,10 @@ export async function script( ); } } + + // Always update pnpm version in workflow files for idempotency + // (handles cases where .projenrc.ts was already fixed but workflows weren't) + await updateWorkflowPnpmVersions(octokit, baseParams, prBranch, repository.full_name); } } catch (error) { // Log error but don't stop the script @@ -260,7 +368,7 @@ export async function script( // Status check information const combinedStatus = - result.resource.commits.nodes[0].commit.statusCheckRollup.state; + result.resource.commits.nodes[0].commit.statusCheckRollup?.state || 'PENDING'; // Approval information const viewerDidApprove = From 4e21167824d2747f6d455e3e0d00535cf516c5d4 Mon Sep 17 00:00:00 2001 From: Andrew Hammond <445764+ahammond@users.noreply.github.com> Date: Tue, 4 Nov 2025 13:59:58 -0800 Subject: [PATCH 4/4] optional merges --- CLAUDE.md | 1 + README.md | 11 ++++++----- script.js | 32 +++++++++++++++++++------------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 097124a..56676eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ The script handles three distinct patterns via the `--majorVersion` parameter: - `--majorVersion` (required): Major version (e.g., `v11`), `all`, or `projen` - `--library` (default: `@time-loop/cdk-library`): Library name (ignored for `all` and `projen`) - `--maxAgeDays` (default: 7): Maximum age in days for merged PRs (only used with `all` and `projen`) +- `--merge` (default: true): Whether to merge PRs. Use `--no-merge` to validate PRs without actually merging them ### PAT Requirements diff --git a/README.md b/README.md index 3c57d4b..8dc0a29 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ node cli.js \ ## Options -| option | type | default | description | -| ----------------- | ------ | ------- | ------------------ | -| `--majorVersion` | string | none | Major version number for the library, for example `v11`. If you provide `all` then it will instead address the `all non-major updates` PR. If you provide `projen`, it will address the `fix(deps): upgrade projen` PR. | -| `--library` | string | `@time-loop/cdk-library` | Full name of library to be updated via renovate | -| `--maxAgeDays` | number | 7 | The maximum age, in days, since when a PR was merge to consider it the relevant PR. Only used by the special cases of `majorVersion` | +| option | type | default | description | +| ----------------- | ------- | ------- | ------------------ | +| `--majorVersion` | string | none | Major version number for the library, for example `v11`. If you provide `all` then it will instead address the `all non-major updates` PR. If you provide `projen`, it will address the `fix(deps): upgrade projen` PR. | +| `--library` | string | `@time-loop/cdk-library` | Full name of library to be updated via renovate | +| `--maxAgeDays` | number | 7 | The maximum age, in days, since when a PR was merge to consider it the relevant PR. Only used by the special cases of `majorVersion` | +| `--merge` | boolean | true | Whether to merge PRs. When set to `false` (using `--no-merge`), the script will validate PRs are ready to merge but will not actually merge them | ### PAT Requirements diff --git a/script.js b/script.js index b2ba4d1..77cdc76 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,4 @@ + // @ts-check const noTouchTopicName = 'octoherd-no-touch'; @@ -107,11 +108,12 @@ async function updateWorkflowPnpmVersions(octokit, baseParams, prBranch, repoFul * @param {string} [options.majorVersion] major version number for the library, for example v11. If you provide `all` then it will instead address the `all non-major updates` PR. If you provide `projen`, it will address the `fix(deps): upgrade projen` PR. * @param {string} [options.library] full name of library to be updated via renovate, for example \@time-loop/cdk-library. Ignored when doing an `all non-major updates`. * @param {number} [options.maxAgeDays] the maximum age, in days, since when a PR was merge to consider it the relevant PR. Ignored except when doing `all non-major updates`. Defaults to 7. + * @param {boolean} [options.merge] whether to merge PRs. Defaults to true. */ export async function script( octokit, repository, - { majorVersion, library = '@time-loop/cdk-library', maxAgeDays = 7 } + { majorVersion, library = '@time-loop/cdk-library', maxAgeDays = 7, merge = true } ) { if (!majorVersion) { throw new Error('--majorVersion is required, example v11'); @@ -465,18 +467,22 @@ export async function script( } } - const commit_title = `${pr.title} (#${pr.number})`; - await octokit.request( - 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', - { - owner: repository.owner.login, - repo: repository.name, - pull_number: pr.number, - commit_title, - merge_method: 'squash', - } - ); - octokit.log.info('pull request merged: %s', pr.html_url); + if (merge) { + const commit_title = `${pr.title} (#${pr.number})`; + await octokit.request( + 'PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge', + { + owner: repository.owner.login, + repo: repository.name, + pull_number: pr.number, + commit_title, + merge_method: 'squash', + } + ); + octokit.log.info('pull request merged: %s', pr.html_url); + } else { + octokit.log.info('pull request ready to merge (merge disabled): %s', pr.html_url); + } return; }