Skip to content

Commit

Permalink
feat!: Update Repo Pruner to create summary issues for inactive branc…
Browse files Browse the repository at this point in the history
…hes instead of opening pull requests

This update fundamentally changes the workflow of Repo Pruner:

- The action now scans inactive branches and creates a GitHub issue summarizing their status instead of opening pull requests for review.
- Added a summary issue that includes details like branch name, last commit date, creator, status (merged/unmerged), and associated pull request (if any).
- Protected branches are ignored.
- Updated logging for improved clarity and more informative messages.
- Removed PR creation logic and associated input options (`base_branch` and reviewer assignments).

BREAKING CHANGES:
- Repo Pruner no longer creates pull requests for inactive branches. Instead, it creates a single summary issue listing all inactive branches.
- Removed `base_branch` input option as it is no longer applicable to the updated workflow.
- Removed PR assignment functionality.

To use the new version, update your GitHub workflow to reflect these changes. Refer to the updated README for more details.
  • Loading branch information
arminbro committed Nov 17, 2024
1 parent f4867cc commit 862c98e
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 85 deletions.
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Repo Pruner

**Repo Pruner** is a GitHub Action designed to help maintain clean and organized repositories by monitoring inactive branches. This tool scans for branches that have been inactive for a specified duration (based on the last commit date) and automatically opens a pull request for each branch. The branch creator is assigned as the reviewer, allowing them to either merge their work or close the PR and delete the branch, ensuring your repository remains streamlined and clutter-free.
**Repo Pruner** is a GitHub Action designed to help maintain clean and organized repositories by monitoring inactive branches. This tool scans for branches that have been inactive for a specified duration (based on the last commit date) and creates a summary issue listing their status. The issue includes details like the branch name, the last commit date, whether the branch has been merged, and any associated pull requests, allowing your team to review and decide on further actions.

## Features
- Scans branches for inactivity based on the number of days since the last commit.
- Ignores protected branches and branches that already have an open pull request.
- Creates a pull request for each inactive branch targeting the base branch.
- Assigns the branch creator as the reviewer for the PR.
- Provides a customizable inactivity threshold and base branch.
- Ignores protected branches and lists all others in a summary issue.
- Displays the status of each branch (e.g., merged, unmerged).
- Includes links to associated pull requests or marks branches without PRs as "None."
- Provides a customizable inactivity threshold.

## Usage
To use **Repo Pruner**, add it to your workflow file:
Expand All @@ -16,7 +16,7 @@ To use **Repo Pruner**, add it to your workflow file:
name: "Run Repo Pruner"
on:
schedule:
- cron: '0 0 * * 0' # Example: You can run it weekly
- cron: '0 0 1 * *' # Example: Runs once a month - At 00:00 on day-of-month 1.
workflow_dispatch:

jobs:
Expand All @@ -29,24 +29,44 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
inactive_days: 30
base_branch: main
```
## Inputs
| Name | Description | Required | Default |
|----------------|----------------------------------------------------------------------------------------------|----------|-------------|
| `inactive_days`| Number of days since the last commit before a branch is considered inactive. | No | `30` |
| `base_branch` | The base branch used for the pull request. | No | `main` |
| Name | Description | Required | Default |
|-----------------|------------------------------------------------------------------------------------------------------|----------|-------------|
| `inactive_days` | Number of days since the last commit before a branch is considered inactive. | No | `30` |

## Output Summary
The action generates a GitHub issue summarizing all inactive branches. Each branch is listed with the following details:
- **Branch Name**: The name of the branch.
- **Last Commit Date**: The date of the last commit on the branch.
- **Creator**: The username of the branch creator.
- **Status**: Indicates whether the branch has been merged into another branch or remains unmerged.
- **Pull Request**: A link to the associated pull request (if any) or "None" if no PR exists.

### Example Issue
```md
### Inactive Branches
This is a list of branches that have been inactive based on the specified threshold.
| Branch | Last Commit Date | Creator | Status | Pull Request |
|--------------|------------------|------------|------------|-----------------------|
| feature-1 | 11/01/2024 | @johndoe | Merged | [PR #42](https://github.com/my-org/my-repo/pull/42) |
| hotfix-123 | 10/15/2024 | @janedoe | Unmerged | None |
| experiment-2 | 10/05/2024 | @janedoe | Unmerged | None |
| feature-3 | 11/05/2024 | @alice | Open | [PR #99](https://github.com/my-org/my-repo/pull/99) |
```

## Environment Variables
- **`GITHUB_TOKEN`** (required): GitHub token for authentication.

## Permissions
Ensure your GitHub Actions workflow has sufficient permissions to:
- **Read branches**
- **Create pull requests**
- **Assign reviewers**
- **List pull requests**
- **Create and update issues**

Using `${{ secrets.GITHUB_TOKEN }}` should provide the necessary permissions for most standard uses.

Expand Down
10 changes: 3 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
name: "Repo Pruner"
description: "Scans for inactive branches, opens PRs for review, and assigns branch creators as reviewers to merge or delete branches."
description: "Scans for inactive branches and creates a summary issue listing their status (merged, unmerged, or without an associated PR) for review."
author: "Armin Broubakarian"

inputs:
inactive_days:
description: "Number of days since the last commit before a branch is considered inactive (default 30)"
description: "Number of days since the last commit before a branch is considered inactive. Branches that haven't had commits in this time frame will be listed in the summary issue. Default is 30."
required: false
default: "30"
base_branch:
description: "Base branch used for the pull request (default 'main')"
required: false
default: "main"

runs:
using: 'node20'
main: 'dist/index.js'

branding:
icon: git-branch
color: green
color: green
147 changes: 82 additions & 65 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,132 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import { createOrUpdateSummaryIssue } from './utils/issueUtils';
import type { InactiveBranch } from './utils/types';

async function run() {
try {
// Get token
const token = process.env.GITHUB_TOKEN;

if (!token) {
throw new Error(`GITHUB_TOKEN environment variable is not set`);
throw new Error(`GITHUB_TOKEN environment variable is not set.`);
}

// Get input values
const inactiveDays = parseInt(core.getInput('inactive_days') || '30');
const baseBranch = core.getInput('base_branch') || 'main';

if (isNaN(inactiveDays) || inactiveDays <= 0) {
throw new Error('The inactive_days input must be a valid positive number.');
}

const octokit = github.getOctokit(token);
const { owner, repo } = github.context.repo;

// Get the current date and calculate the threshold date
const currentDate = new Date();
// Calculate the threshold date for inactivity
const thresholdDate = new Date();
thresholdDate.setDate(currentDate.getDate() - inactiveDays);
thresholdDate.setDate(thresholdDate.getDate() - inactiveDays);

// List branches in the repository
const { data: branches } = await octokit.rest.repos.listBranches({
owner,
repo,
});

if (branches.length === 0) {
core.info('No branches found in the repository.');
return;
}

let inactiveBranches: InactiveBranch[] = [];

for (const branch of branches) {
// Skip the base branch
if (branch.name === baseBranch) {
core.info(`Skipping base branch: ${branch.name}`);
continue;
}
core.info(`Processing branch: ${branch.name}`);

// Skip protected branches
if (branch.protected) {
core.info(`Skipping protected branch: ${branch.name}`);
continue;
}

// Check if a PR already exists for the branch
const { data: prs } = await octokit.rest.pulls.list({
owner,
repo,
head: `${owner}:${branch.name}`,
state: 'open',
});

// Skip branches that already has an open PR.
if (prs.length > 0) {
core.info(`Skipping branch ${branch.name} as it already has an open PR.`);
// Get the last commit date
let commitData;

try {
const { data } = await octokit.rest.repos.getCommit({
owner,
repo,
ref: branch.commit.sha,
});

commitData = data;
} catch (error) {
core.warning(`Failed to fetch commit data for branch ${branch.name}: ${error}`);
continue;
}

// Get the last commit of the branch
const { data } = await octokit.rest.repos.getCommit({
owner,
repo,
ref: branch.commit.sha,
});

const lastCommitDate = data.commit.author?.date ? new Date(data.commit.author.date) : null;
const lastCommitDate = commitData.commit.author?.date ? new Date(commitData.commit.author.date) : null;

// if lastCommitDate is null
if (!lastCommitDate) {
throw new Error(`Branch ${branch.name} is missing the last commit date.`);
core.info(`Skipping branch due to missing last commit date: ${branch.name}`);
continue;
}

// Check if the branch is inactive
if (lastCommitDate < thresholdDate) {
// Branch is inactive
const branchName = branch.name;
const creator = data.author?.login || 'unknown';

// Create a pull request for the branch
const prTitle = `Review: Inactive branch '${branchName}'`;
const prBody = `
### Inactive Branch Notice
This branch has been inactive since ${lastCommitDate.toISOString().split('T')[0]}.
You have been assigned as the reviewer. If the work is complete, please merge this branch and delete it. Otherwise, close this PR and delete this branch.
Cc: @${creator}
`;

const prResponse = await octokit.rest.pulls.create({
owner,
repo,
title: prTitle,
head: branchName,
base: baseBranch,
body: prBody,
});

const prNumber = prResponse.data.number;

// Assign the creator as a reviewer to the PR
await octokit.rest.pulls.requestReviewers({
owner,
repo,
pull_number: prNumber,
reviewers: [creator],
const creator = commitData.author?.login || 'unknown';
let isMerged = false;
let prNumber = undefined;

// Check if this branch has any PRs
try {
const { data: prs } = await octokit.rest.pulls.list({
owner,
repo,
head: `${owner}:${branch.name}`,
});

if (prs.length > 0) {
prNumber = prs[0].number; // Take the first PR associated with the branch

try {
const { data: prDetails } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});

isMerged = prDetails.merged; // Check if the PR was merged
} catch (error) {
core.warning(`Failed to fetch PR details for PR #${prNumber}: ${error}`);
}
}
} catch (error) {
core.warning(`Failed to list PRs for branch ${branch.name}: ${error}`);
}

// Add branch to inactive list with relevant details
inactiveBranches.push({
name: branch.name,
lastCommitDate: lastCommitDate.toLocaleDateString(),
creator,
isMerged,
prNumber: isMerged ? prNumber : undefined,
});

core.info(`Pull request created for branch: ${branchName} and reviewer assigned: ${creator}`);
core.info(`Added branch to inactive list: ${branch.name}`);
}
}

if (inactiveBranches.length > 0) {
await createOrUpdateSummaryIssue(owner, repo, inactiveBranches);
return;
}

core.info('No inactive branches found.');
} catch (error) {
if (error instanceof Error) {
return core.setFailed(`Action failed with error: ${error.message}`);
core.setFailed(`Action failed with error: ${error.message}`);
return;
}

core.setFailed('Action failed with an unknown error');
Expand Down
74 changes: 74 additions & 0 deletions src/utils/issueUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import type { InactiveBranch } from './types';

export async function createOrUpdateSummaryIssue(
owner: string,
repo: string,
inactiveBranches: InactiveBranch[]
): Promise<void> {
// Get token
const token = process.env.GITHUB_TOKEN;

if (!token) {
throw new Error('GITHUB_TOKEN environment variable is not set.');
}

const octokit = github.getOctokit(token);
const issueTitle = 'Repo Pruner: Inactive Branches Summary';

// Construct the issue body
const tableHeader = `
| Branch | Last Commit Date | Creator | Status | Pull Request |
|--------|------------------|---------|------------|--------------|`;

const tableRows = inactiveBranches.map((branch) => {
const status = branch.isMerged ? 'Merged' : 'Unmerged';
const prLink = branch.prNumber
? `[PR #${branch.prNumber}](https://github.com/${owner}/${repo}/pull/${branch.prNumber})`
: 'None';

return `| ${branch.name} | ${branch.lastCommitDate} | @${branch.creator} | ${status} | ${prLink} |`;
});

const issueBody = `### Inactive Branches
This is a list of branches that have been inactive based on the specified threshold.
${tableHeader}
${tableRows.join('\n')}`;

// Check if an existing summary issue is open
const { data: issues } = await octokit.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: 'Repo Pruner Summary',
});

if (issues.length > 0) {
// Update the existing issue
const issueNumber = issues[0].number;

await octokit.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
body: issueBody,
});

core.info(`Updated existing summary issue #${issueNumber}`);
return;
}

// Create a new summary issue
await octokit.rest.issues.create({
owner,
repo,
title: issueTitle,
body: issueBody,
labels: ['Repo Pruner Summary'],
});

core.info('Created a new summary issue for inactive branches.');
}
18 changes: 18 additions & 0 deletions src/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Represents a single inactive branch's details.
*/
export interface InactiveBranch {
name: string; // The branch name
lastCommitDate: string; // Formatted date of the last commit
creator: string; // GitHub username of the branch creator
isMerged: boolean; // Whether the branch is fully merged into the base branch
prNumber?: number; // Optional field for the PR number
}

/**
* General repository information.
*/
export interface RepositoryInfo {
owner: string; // The repository owner
repo: string; // The repository name
}

0 comments on commit 862c98e

Please sign in to comment.