Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/scripts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
210 changes: 210 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# PR Quota Manager - Manual Execution Guide

This document explains how to run the PR Quota Manager script manually from the command line for testing and troubleshooting.

## Prerequisites

1. **Node.js** (version 16 or higher)
```bash
node --version
```

2. **GitHub Personal Access Token** with the following permissions:
- `repo` (Full control of private repositories)
- `public_repo` (Access public repositories) - if working with public repos only

Create a token at: https://github.com/settings/tokens.
Store the value in a file, e.g. `~/.github_token`.
Then set the environment variable:
```bash
read -r GITHUB_TOKEN < ~/.github_token
export GITHUB_TOKEN
```

3. **Install Dependencies**

Navigate to the `.github/scripts` directory and install dependencies:
```bash
cd .github/scripts
npm ci
```

## Running the Script

### Basic Usage

```bash
node pr-quota-manager.js <username> [owner] [repo]
```

### Parameters

- `username` (required): The GitHub username to process quota for
- `owner` (optional): Repository owner (defaults to `jaegertracing` or `GITHUB_REPOSITORY` env var)
- `repo` (optional): Repository name (defaults to `jaeger` or `GITHUB_REPOSITORY` env var)

### Examples

**Process quota for a specific user in the default repository:**
```bash
node pr-quota-manager.js newcontributor
```

**Process quota for a user in a different repository:**
```bash
node pr-quota-manager.js username myorg myrepo
```

**Using environment variables for repository:**
```bash
export GITHUB_REPOSITORY="jaegertracing/jaeger"
node pr-quota-manager.js contributor
```

### Dry-Run Mode

Test the script without making any actual changes:

```bash
# Using flag
node pr-quota-manager.js username --dry-run

# Using environment variable
DRY_RUN=true node pr-quota-manager.js username
```

In dry-run mode, the script will:
- Show exactly what actions it would take
- Not create/modify labels
- Not post comments
- Not make any API modifications
- Still fetch and analyze PRs for accurate simulation

## Listing Open PRs by Author

Use the utility script to see all open PRs grouped by author:

```bash
node list-open-prs-by-author.js [owner] [repo]
```

This is useful for:
- Identifying which users need quota processing
- Planning backfills of the quota system
- Seeing which PRs are already quota-blocked

**CSV output for scripting:**
```bash
FORMAT=csv node list-open-prs-by-author.js > prs.csv
```

## Output

The script will display:

1. **History Audit**: Summary of merged PR count (up to 3 merged PRs for quota calculation)
2. **Current Stats**: Merged count, calculated quota, and open PR count
3. **Processing Actions**: Each PR being blocked/unblocked
4. **Summary**: Total counts of blocked, unblocked, and unchanged PRs

### Example Output

```
=== Processing Quota for: @newuser ===

📜 History Audit:
No merged PRs found.

📊 Current Stats:
User has 0 merged PRs. Current Quota: 1. Currently Open: 3.

🔄 Processing Open PRs:

ℹ️ PR #123 unchanged (active)
✅ Labeled PR #124 as blocked (Position: 2/3, Quota: 1)
✅ Labeled PR #125 as blocked (Position: 3/3, Quota: 1)

✅ Processing Complete for @newuser

📋 Summary:
- Blocked: 2 PRs
- Unblocked: 0 PRs
- Unchanged: 1 PRs
```

## Running Tests

To run the unit tests:

```bash
cd .github/scripts
npm test
```

To run tests with coverage:

```bash
npm test -- --coverage
```

## Quota Rules

The script applies the following quota rules:

| Merged PRs | Quota |
|-----------|-------|
| 0 | 1 |
| 1 | 2 |
| 2 | 3 |
| 3+ | 10 |

## Troubleshooting

### "GITHUB_TOKEN environment variable is required"

Make sure you've set the `GITHUB_TOKEN` environment variable:
```bash
export GITHUB_TOKEN="your_token_here"
```

### "403 Forbidden" errors

Your GitHub token may not have the required permissions. Ensure it has:
- `repo` scope for private repositories
- `public_repo` scope for public repositories

### "Cannot find module '@octokit/rest'"

Install the required dependency:
```bash
cd .github/scripts
npm install @octokit/rest
```

### API Rate Limiting

GitHub has rate limits for API requests:
- Authenticated requests: 5,000 requests per hour
- The script makes approximately 2-5 API calls per user

If you hit rate limits, wait for the limit to reset or use a different token.

## How It Works

1. **Fetches PRs** by the target author (all open PRs + up to 3 merged PRs for quota calculation)
2. **Calculates quota** based on the number of merged PRs
3. **Identifies open PRs** and sorts them by creation date (oldest first)
4. **Applies labels** to PRs based on quota:
- PRs within quota: Remove `pr-quota-reached` label (if present)
- PRs exceeding quota: Add `pr-quota-reached` label
5. **Posts comments** (only on state changes to avoid spam):
- Blocking comment when PR first gets blocked
- Unblocking comment when PR is moved to active queue

## Integration with GitHub Actions

This script is automatically executed by the GitHub Actions workflow (`.github/workflows/pr-quota-manager.yml`) on:
- Pull request opened, closed, or reopened events
- Manual workflow dispatch

The workflow uses `actions/github-script` to run the script with the repository's built-in `GITHUB_TOKEN`.
170 changes: 170 additions & 0 deletions .github/scripts/list-open-prs-by-author.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env node

/**
* List Open PRs Grouped by Author
*
* This utility script lists all open PRs in a repository grouped by author.
* Useful for identifying which users need quota processing or backfilling.
*
* Usage:
* GITHUB_TOKEN=<token> node list-open-prs-by-author.js [owner] [repo]
*/

/**
* Fetch all open PRs grouped by author
* @param {object} octokit - GitHub API client
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<Map>} Map of author -> array of PRs
*/
async function fetchOpenPRsByAuthor(octokit, owner, repo) {
const prsByAuthor = new Map();
let page = 1;
const perPage = 100;

console.log(`📥 Fetching open PRs from ${owner}/${repo}...`);

while (true) {
const { data } = await octokit.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: perPage,
page,
sort: 'created',
direction: 'asc'
});

if (data.length === 0) break;

for (const pr of data) {
const author = pr.user.login;
if (!prsByAuthor.has(author)) {
prsByAuthor.set(author, []);
}
prsByAuthor.get(author).push({
number: pr.number,
title: pr.title,
created_at: pr.created_at,
labels: pr.labels.map(l => l.name)
});
}

if (data.length < perPage) break;
page++;
}

return prsByAuthor;
}

/**
* Display PRs grouped by author
* @param {Map} prsByAuthor - Map of author -> PRs
*/
function displayResults(prsByAuthor) {
// Sort by number of open PRs (descending)
const sortedAuthors = Array.from(prsByAuthor.entries())
.sort((a, b) => b[1].length - a[1].length);

console.log(`\n📊 Found ${sortedAuthors.length} authors with open PRs\n`);
console.log('=' .repeat(80));

for (const [author, prs] of sortedAuthors) {
const hasQuotaLabel = prs.some(pr => pr.labels.includes('pr-quota-reached'));
const quotaIndicator = hasQuotaLabel ? ' 🚫' : '';

console.log(`\n👤 @${author} (${prs.length} open PR${prs.length > 1 ? 's' : ''})${quotaIndicator}`);

// Sort PRs by creation date (oldest first)
const sortedPRs = prs.sort((a, b) =>
new Date(a.created_at) - new Date(b.created_at)
);

for (const pr of sortedPRs) {
const date = new Date(pr.created_at).toISOString().split('T')[0];
const quotaLabel = pr.labels.includes('pr-quota-reached') ? ' [QUOTA REACHED]' : '';
console.log(` - PR #${pr.number}: ${pr.title.substring(0, 70)}${pr.title.length > 70 ? '...' : ''}`);
console.log(` Created: ${date}${quotaLabel}`);
}
}

console.log('\n' + '='.repeat(80));
console.log(`\n📋 Summary:`);
console.log(` Total authors: ${sortedAuthors.length}`);
console.log(` Total open PRs: ${Array.from(prsByAuthor.values()).reduce((sum, prs) => sum + prs.length, 0)}`);

const authorsWithQuota = sortedAuthors.filter(([_, prs]) =>
prs.some(pr => pr.labels.includes('pr-quota-reached'))
).length;
if (authorsWithQuota > 0) {
console.log(` Authors with quota-blocked PRs: ${authorsWithQuota}`);
}
}

/**
* Display in CSV format for easy processing
* @param {Map} prsByAuthor - Map of author -> PRs
*/
function displayCSV(prsByAuthor) {
console.log('Author,PR Count,PR Numbers,Has Quota Label');

for (const [author, prs] of prsByAuthor.entries()) {
const prNumbers = prs.map(pr => `#${pr.number}`).join(' ');
const hasQuotaLabel = prs.some(pr => pr.labels.includes('pr-quota-reached'));
console.log(`${author},${prs.length},"${prNumbers}",${hasQuotaLabel}`);
}
}

/**
* Main execution function
*/
async function main() {
const args = process.argv.slice(2);

const owner = args[0] || process.env.GITHUB_REPOSITORY?.split('/')[0] || 'jaegertracing';
const repo = args[1] || process.env.GITHUB_REPOSITORY?.split('/')[1] || 'jaeger';
const format = process.env.FORMAT || 'default'; // 'default' or 'csv'

if (!process.env.GITHUB_TOKEN) {
console.error('Error: GITHUB_TOKEN environment variable is required');
console.error('Usage: GITHUB_TOKEN=<token> node list-open-prs-by-author.js [owner] [repo]');
console.error('Optional: FORMAT=csv for CSV output');
process.exit(1);
}

// Import @octokit/rest dynamically
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN
});

try {
const prsByAuthor = await fetchOpenPRsByAuthor(octokit, owner, repo);

if (format === 'csv') {
displayCSV(prsByAuthor);
} else {
displayResults(prsByAuthor);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}

// Export for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fetchOpenPRsByAuthor,
displayResults,
displayCSV
};
}

// Run main function if executed directly
if (require.main === module) {
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
}
Loading
Loading