Skip to content

Stale Check

Stale Check #25

Workflow file for this run

# ------------------------------------------------------------------------------------
# Stale Check Workflow
#
# Purpose: Warn about and close inactive issues and PRs to maintain repository hygiene.
# This workflow identifies stale items, marks them with a label, and eventually closes
# them if no activity occurs within the configured timeframe.
#
# Configuration: All settings are loaded from .github/.env.shared for centralized
# management across all workflows.
#
# Triggers:
# - Scheduled: Monday-Friday at 08:32 UTC
# - Manual: Via workflow_dispatch
#
# Maintainer: @mrz1836
#
# ------------------------------------------------------------------------------------
name: Stale Check
# ————————————————————————————————————————————————————————————————
# Trigger Configuration
# ————————————————————————————————————————————————————————————————
on:
schedule:
# ┌─ min ─┬─ hour ─┬─ dom ─┬─ mon ─┬─ dow ─┐
- cron: "0 12 * * 1-5" # 7:00 AM EST (12:00 UTC)
workflow_dispatch: # Allow manual triggering
# ————————————————————————————————————————————————————————————————
# Permissions
# ————————————————————————————————————————————————————————————————
permissions:
contents: read
# ————————————————————————————————————————————————————————————————
# Concurrency Control
# ————————————————————————————————————————————————————————————————
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ----------------------------------------------------------------------------------
# Load Environment Variables from .env.shared
# ----------------------------------------------------------------------------------
load-env:
name: 🌍 Load Environment Variables
runs-on: ubuntu-latest
outputs:
env-json: ${{ steps.load-env.outputs.env-json }}
steps:
# ————————————————————————————————————————————————————————————————
# Check out code to access env file
# ————————————————————————————————————————————————————————————————
- name: 📥 Checkout code (sparse)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
sparse-checkout: |
.github/.env.shared
.github/actions/load-env
# ————————————————————————————————————————————————————————————————
# Load and parse environment file
# ————————————————————————————————————————————————————————————————
- name: 🌍 Load environment variables
uses: ./.github/actions/load-env
id: load-env
# ----------------------------------------------------------------------------------
# Main Stale Check Job
# ----------------------------------------------------------------------------------
stale-check:
name: 🧹 Process Stale Items
needs: [load-env]
runs-on: ubuntu-latest
permissions:
issues: write # Required to add labels and comments
pull-requests: write # Required to add labels and comments on PRs
steps:
# ————————————————————————————————————————————————————————————————
# Log token configuration
# ————————————————————————————————————————————————————————————————
- name: 🔑 Log token configuration
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
run: |
PREFERRED_TOKEN=$(echo "$ENV_JSON" | jq -r '.PREFERRED_GITHUB_TOKEN')
if [[ "$PREFERRED_TOKEN" == "GH_PAT_TOKEN" && -n "${{ secrets.GH_PAT_TOKEN }}" ]]; then
echo "✅ Using Personal Access Token (PAT) for stale check operations"
else
echo "✅ Using default GITHUB_TOKEN for stale check operations"
fi
# ————————————————————————————————————————————————————————————————
# Extract environment variables
# ————————————————————————————————————————————————————————————————
- name: 🔧 Extract stale configuration
id: config
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
run: |
echo "🎯 Extracting stale workflow configuration..."
# Extract stale-specific variables from JSON
DAYS_BEFORE_STALE=$(echo "$ENV_JSON" | jq -r '.STALE_DAYS_BEFORE_STALE')
DAYS_BEFORE_CLOSE=$(echo "$ENV_JSON" | jq -r '.STALE_DAYS_BEFORE_CLOSE')
STALE_LABEL=$(echo "$ENV_JSON" | jq -r '.STALE_LABEL')
EXEMPT_ISSUE_LABELS=$(echo "$ENV_JSON" | jq -r '.STALE_EXEMPT_ISSUE_LABELS')
EXEMPT_PR_LABELS=$(echo "$ENV_JSON" | jq -r '.STALE_EXEMPT_PR_LABELS')
OPERATIONS_PER_RUN=$(echo "$ENV_JSON" | jq -r '.STALE_OPERATIONS_PER_RUN')
# Export to outputs
echo "days-before-stale=$DAYS_BEFORE_STALE" >> $GITHUB_OUTPUT
echo "days-before-close=$DAYS_BEFORE_CLOSE" >> $GITHUB_OUTPUT
echo "stale-label=$STALE_LABEL" >> $GITHUB_OUTPUT
echo "exempt-issue-labels=$EXEMPT_ISSUE_LABELS" >> $GITHUB_OUTPUT
echo "exempt-pr-labels=$EXEMPT_PR_LABELS" >> $GITHUB_OUTPUT
echo "operations-per-run=$OPERATIONS_PER_RUN" >> $GITHUB_OUTPUT
echo "✅ Configuration extracted successfully"
# ————————————————————————————————————————————————————————————————
# Calculate cutoff dates for stale detection
# ————————————————————————————————————————————————————————————————
- name: 📅 Calculate cutoff dates
id: dates
run: |
echo "⏱️ Calculating stale and close cutoff dates..."
# Calculate dates for stale marking and closing
DAYS_BEFORE_STALE="${{ steps.config.outputs.days-before-stale }}"
DAYS_BEFORE_CLOSE="${{ steps.config.outputs.days-before-close }}"
stale_date=$(date -d "$DAYS_BEFORE_STALE days ago" --iso-8601)
close_date=$(date -d "$(( $DAYS_BEFORE_STALE + $DAYS_BEFORE_CLOSE )) days ago" --iso-8601)
echo "stale_cutoff=${stale_date}" >> $GITHUB_OUTPUT
echo "close_cutoff=${close_date}" >> $GITHUB_OUTPUT
echo "📊 === Stale Check Configuration ==="
echo "🔸 Stale cutoff date: ${stale_date} (${DAYS_BEFORE_STALE} days ago)"
echo "🔸 Close cutoff date: ${close_date} ($(( ${DAYS_BEFORE_STALE} + ${DAYS_BEFORE_CLOSE} )) days ago)"
echo "🔸 Stale label: ${{ steps.config.outputs.stale-label }}"
echo "🔸 Operations limit: ${{ steps.config.outputs.operations-per-run }}"
echo "✅ Date calculations complete"
# ————————————————————————————————————————————————————————————————
# Process issues for stale marking and closing
# ————————————————————————————————————————————————————————————————
- name: 📋 Process stale issues
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
with:
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const staleCutoff = '${{ steps.dates.outputs.stale_cutoff }}';
const closeCutoff = '${{ steps.dates.outputs.close_cutoff }}';
const staleLabel = '${{ steps.config.outputs.stale-label }}';
const exemptLabels = '${{ steps.config.outputs.exempt-issue-labels }}'.split(',').map(l => l.trim()).filter(l => l);
const operationsLimit = parseInt('${{ steps.config.outputs.operations-per-run }}');
const daysBeforeClose = parseInt('${{ steps.config.outputs.days-before-close }}');
const envJson = JSON.parse(process.env.ENV_JSON);
const preferredToken = envJson.PREFERRED_GITHUB_TOKEN;
const isUsingPAT = preferredToken === 'GH_PAT_TOKEN' && '${{ secrets.GH_PAT_TOKEN }}' !== '';
console.log('📋 === Processing Issues ===');
console.log(`🏷️ Exempt labels: ${exemptLabels.join(', ')}`);
console.log(`🔑 Token type: ${isUsingPAT ? 'Personal Access Token (PAT)' : 'Default GITHUB_TOKEN'}`);
let operationsCount = 0;
let processedCount = 0;
let markedStaleCount = 0;
let closedCount = 0;
// Helper function to check if issue has exempt labels
function hasExemptLabel(issue) {
const issueLabels = issue.labels.map(label => label.name);
return exemptLabels.some(exempt => issueLabels.includes(exempt));
}
// Helper function to check if issue is already stale
function isAlreadyStale(issue) {
return issue.labels.some(label => label.name === staleLabel);
}
// Get all open issues with pagination
const iterator = github.paginate.iterator(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc',
per_page: 100
});
for await (const { data: issues } of iterator) {
for (const issue of issues) {
// Skip pull requests (they're handled separately)
if (issue.pull_request) continue;
// Stop if we've hit our operations limit
if (operationsCount >= operationsLimit) {
console.log(`⚠️ Reached operations limit (${operationsLimit}), stopping`);
break;
}
processedCount++;
const updatedAt = new Date(issue.updated_at);
const daysSinceUpdate = Math.floor((Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
console.log(`🔍 Processing issue #${issue.number}: "${issue.title}" (updated ${daysSinceUpdate} days ago)`);
// Skip if issue has exempt labels
if (hasExemptLabel(issue)) {
console.log(` ⏭️ Skipping: has exempt label`);
continue;
}
const alreadyStale = isAlreadyStale(issue);
// Check if issue should be closed (already stale + past close cutoff)
if (alreadyStale && updatedAt < new Date(closeCutoff)) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `This issue was automatically closed after **${daysSinceUpdate} days** of inactivity. If this is still relevant, feel free to re-open.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed'
});
console.log(` ✅ Closed issue #${issue.number}`);
closedCount++;
operationsCount += 2;
} catch (error) {
console.log(` ❌ Failed to close issue #${issue.number}: ${error.message}`);
}
}
// Check if issue should be marked as stale
else if (!alreadyStale && updatedAt < new Date(staleCutoff)) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [staleLabel]
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: `This issue has been inactive for **${daysSinceUpdate} days** and will be closed in ${daysBeforeClose} days if no further activity occurs.`
});
console.log(` 🏷️ Marked issue #${issue.number} as stale`);
markedStaleCount++;
operationsCount += 2;
} catch (error) {
console.log(` ❌ Failed to mark issue #${issue.number} as stale: ${error.message}`);
}
}
else {
console.log(` ✅ Issue #${issue.number} is still active`);
}
}
if (operationsCount >= operationsLimit) break;
}
console.log('\n📊 === Issues Summary ===');
console.log(`✅ Processed: ${processedCount} issues`);
console.log(`🏷️ Marked stale: ${markedStaleCount} issues`);
console.log(`🔒 Closed: ${closedCount} issues`);
console.log(`⚡ Operations used: ${operationsCount}/${operationsLimit}`);
# ————————————————————————————————————————————————————————————————
# Process pull requests for stale marking and closing
# ————————————————————————————————————————————————————————————————
- name: 🔀 Process stale pull requests
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
with:
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const staleCutoff = '${{ steps.dates.outputs.stale_cutoff }}';
const closeCutoff = '${{ steps.dates.outputs.close_cutoff }}';
const staleLabel = '${{ steps.config.outputs.stale-label }}';
const exemptLabels = '${{ steps.config.outputs.exempt-pr-labels }}'.split(',').map(l => l.trim()).filter(l => l);
const operationsLimit = parseInt('${{ steps.config.outputs.operations-per-run }}');
const daysBeforeClose = parseInt('${{ steps.config.outputs.days-before-close }}');
console.log('\n🔀 === Processing Pull Requests ===');
console.log(`🏷️ Exempt labels: ${exemptLabels.join(', ')}`);
let operationsCount = 0;
let processedCount = 0;
let markedStaleCount = 0;
let closedCount = 0;
// Helper functions (same as issues)
function hasExemptLabel(pr) {
const prLabels = pr.labels.map(label => label.name);
return exemptLabels.some(exempt => prLabels.includes(exempt));
}
function isAlreadyStale(pr) {
return pr.labels.some(label => label.name === staleLabel);
}
// Get all open pull requests with pagination
const iterator = github.paginate.iterator(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc',
per_page: 100
});
for await (const { data: prs } of iterator) {
for (const pr of prs) {
// Stop if we've hit our operations limit
if (operationsCount >= operationsLimit) {
console.log(`⚠️ Reached operations limit (${operationsLimit}), stopping`);
break;
}
processedCount++;
const updatedAt = new Date(pr.updated_at);
const daysSinceUpdate = Math.floor((Date.now() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
console.log(`🔍 Processing PR #${pr.number}: "${pr.title}" (updated ${daysSinceUpdate} days ago)`);
// Skip draft PRs
if (pr.draft) {
console.log(` ⏭️ Skipping: draft PR`);
continue;
}
// Skip if PR has exempt labels
if (hasExemptLabel(pr)) {
console.log(` ⏭️ Skipping: has exempt label`);
continue;
}
const alreadyStale = isAlreadyStale(pr);
// Check if PR should be closed (already stale + past close cutoff)
if (alreadyStale && updatedAt < new Date(closeCutoff)) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `This PR was automatically closed after **${daysSinceUpdate} days** of inactivity. If you plan to resume work, please re-open.`
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
});
console.log(` ✅ Closed PR #${pr.number}`);
closedCount++;
operationsCount += 2;
} catch (error) {
console.log(` ❌ Failed to close PR #${pr.number}: ${error.message}`);
}
}
// Check if PR should be marked as stale
else if (!alreadyStale && updatedAt < new Date(staleCutoff)) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [staleLabel]
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `This pull request has been inactive for **${daysSinceUpdate} days** and will be closed in ${daysBeforeClose} days if no further activity occurs.`
});
console.log(` 🏷️ Marked PR #${pr.number} as stale`);
markedStaleCount++;
operationsCount += 2;
} catch (error) {
console.log(` ❌ Failed to mark PR #${pr.number} as stale: ${error.message}`);
}
}
else {
console.log(` ✅ PR #${pr.number} is still active`);
}
}
if (operationsCount >= operationsLimit) break;
}
console.log('\n📊 === Pull Requests Summary ===');
console.log(`✅ Processed: ${processedCount} PRs`);
console.log(`🏷️ Marked stale: ${markedStaleCount} PRs`);
console.log(`🔒 Closed: ${closedCount} PRs`);
console.log(`⚡ Operations used: ${operationsCount}/${operationsLimit}`);
# ————————————————————————————————————————————————————————————————
# Clean up stale labels from recently updated items
# ————————————————————————————————————————————————————————————————
- name: 🏷️ Remove stale labels from updated items
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GH_PAT_TOKEN != '' && secrets.GH_PAT_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const staleCutoff = new Date('${{ steps.dates.outputs.stale_cutoff }}');
const staleLabel = '${{ steps.config.outputs.stale-label }}';
console.log('\n🏷️ === Cleaning Stale Labels ===');
console.log('🔍 Looking for recently updated items with stale labels...');
let removedCount = 0;
let checkedCount = 0;
// Helper function to check if item should have stale label removed
function shouldRemoveStaleLabel(item) {
const updatedAt = new Date(item.updated_at);
return updatedAt > staleCutoff;
}
// Process issues with stale label
console.log('📋 Checking issues...');
const issuesIterator = github.paginate.iterator(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: staleLabel,
per_page: 100
});
for await (const { data: issues } of issuesIterator) {
for (const issue of issues) {
// Skip pull requests (they're handled separately)
if (issue.pull_request) continue;
checkedCount++;
if (shouldRemoveStaleLabel(issue)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: staleLabel
});
console.log(` ✅ Removed stale label from issue #${issue.number}: "${issue.title}"`);
removedCount++;
} catch (error) {
if (error.status === 404) {
console.log(` ℹ️ Label not found on issue #${issue.number} (already removed)`);
} else {
console.log(` ❌ Failed to remove stale label from issue #${issue.number}: ${error.message}`);
}
}
}
}
}
// Process pull requests with stale label
console.log('\n🔀 Checking pull requests...');
const prsIterator = github.paginate.iterator(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
});
for await (const { data: prs } of prsIterator) {
for (const pr of prs) {
// Check if PR has stale label
const prDetails = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});
const hasStaleLabel = prDetails.data.labels.some(label => label.name === staleLabel);
if (hasStaleLabel) {
checkedCount++;
if (shouldRemoveStaleLabel(pr)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: staleLabel
});
console.log(` ✅ Removed stale label from PR #${pr.number}: "${pr.title}"`);
removedCount++;
} catch (error) {
if (error.status === 404) {
console.log(` ℹ️ Label not found on PR #${pr.number} (already removed)`);
} else {
console.log(` ❌ Failed to remove stale label from PR #${pr.number}: ${error.message}`);
}
}
}
}
}
}
console.log(`\n📊 === Label Cleanup Summary ===`);
console.log(`🔍 Checked: ${checkedCount} items with stale label`);
console.log(`✅ Removed stale labels from: ${removedCount} items`);
# ————————————————————————————————————————————————————————————————
# Generate a workflow summary report
# ————————————————————————————————————————————————————————————————
- name: 📊 Generate workflow summary
env:
ENV_JSON: ${{ needs.load-env.outputs.env-json }}
run: |
echo "🚀 Generating workflow summary..."
# Determine which token was used
PREFERRED_TOKEN=$(echo "$ENV_JSON" | jq -r '.PREFERRED_GITHUB_TOKEN')
if [[ "$PREFERRED_TOKEN" == "GH_PAT_TOKEN" && -n "${{ secrets.GH_PAT_TOKEN }}" ]]; then
TOKEN_TYPE="🔑 Personal Access Token (PAT)"
else
TOKEN_TYPE="🔑 Default GITHUB_TOKEN"
fi
echo "# 🧹 Stale Check Workflow Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**⏰ Completed:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## ⚙️ Configuration" >> $GITHUB_STEP_SUMMARY
echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY
echo "|---------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Days before stale | ${{ steps.config.outputs.days-before-stale }} |" >> $GITHUB_STEP_SUMMARY
echo "| Days before close | ${{ steps.config.outputs.days-before-close }} |" >> $GITHUB_STEP_SUMMARY
echo "| Stale label | ${{ steps.config.outputs.stale-label }} |" >> $GITHUB_STEP_SUMMARY
echo "| Operations limit | ${{ steps.config.outputs.operations-per-run }} |" >> $GITHUB_STEP_SUMMARY
echo "| Token type | $TOKEN_TYPE |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## 🏷️ Exempt Labels" >> $GITHUB_STEP_SUMMARY
echo "- **Issues:** ${{ steps.config.outputs.exempt-issue-labels }}" >> $GITHUB_STEP_SUMMARY
echo "- **Pull Requests:** ${{ steps.config.outputs.exempt-pr-labels }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📋 _Check the job logs above for detailed processing statistics._" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ **Stale check workflow completed successfully!**" >> $GITHUB_STEP_SUMMARY