Skip to content

Instantly share code, notes, and snippets.

@kibotu
Last active January 28, 2026 12:11
Show Gist options
  • Select an option

  • Save kibotu/cfed790ccbdcb06ed4aac5f70499b9a3 to your computer and use it in GitHub Desktop.

Select an option

Save kibotu/cfed790ccbdcb06ed4aac5f70499b9a3 to your computer and use it in GitHub Desktop.
Stalebot for Groovy Jenkins Pipeline & Bitbucket Cloud
/**
* Jenkins Stale Branch Bot - Standalone Gist
*
* This script identifies and optionally deletes stale branches in a Git repository.
* It analyzes branches for:
* - Merged branches (fully merged to master with no unique commits)
* - Stale branches (no commits in the last X days)
* - Active branches (recent activity)
*
* Features:
* - Dry-run mode for safe testing
* - Slack notifications for deleted branches
* - Tags branches before deletion for recovery
* - Comprehensive summary reports
* - Concurrent execution prevention via Jenkins locks
*
* Usage in Jenkinsfile:
*
* @Library('your-shared-library') _
*
* pipeline {
* agent any
* parameters {
* string(name: 'DAYS', defaultValue: '90', description: 'Days of inactivity before branch is stale')
* booleanParam(name: 'DELETE_MODE', defaultValue: false, description: 'Enable deletion (unchecked = dry-run)')
* string(name: 'SLACK_CHANNEL', defaultValue: '#stale-branches', description: 'Slack channel for notifications')
* }
* stages {
* stage('Stale Branch Cleanup') {
* steps {
* script {
* purgeBranchesOlderThan(
* params.DAYS.toInteger(),
* params.DELETE_MODE,
* params.SLACK_CHANNEL
* )
* }
* }
* }
* }
* }
*/
/**
* Main entry point: Identifies and optionally deletes stale branches older than specified days.
* Branches are stale if they have no commits in the last X days.
* Merged branches (no commits ahead of master) are also identified for cleanup.
* Uses Jenkins lock to prevent concurrent executions per repository.
*
* @param days Number of days of inactivity before a branch is considered stale
* @param updateRemote If true, deletes branches; if false, dry-run mode
* @param channel Slack channel for notifications (e.g., '#stale-branches')
*/
def purgeBranchesOlderThan(days, updateRemote, channel) {
if (days == null || days <= 0 || days > 1600000000) error("Invalid days: $days")
if (updateRemote == null) error("Invalid updateRemote: $updateRemote")
// Extract repository info from environment
def repoName = env.BITBUCKET_REPOSITORY ?: env.GIT_URL.replaceAll(/\.git$/, "").split(/[\/:]/)[-1]
def repoOwner = env.BITBUCKET_OWNER ?: "group"
// Use lock to prevent concurrent stale bot executions for the same repository
def lockName = "stale-bot-${repoOwner}-${repoName}"
lock(resource: lockName, skipIfLocked: false) {
echo "Stale Bot: Acquired lock for $lockName"
echo "Stale Bot: Analyzing branches older than $days days (${updateRemote ? 'DELETE mode' : 'DRY-RUN mode'})"
echo "Repository: $repoOwner/$repoName"
// Setup isolated git workspace
def tmpDir = "${pwd()}/stale-bot-tmp"
sh "rm -rf $tmpDir && git clone ${gitUrl()} $tmpDir"
def remoteName = sh(script: "git -C $tmpDir remote", returnStdout: true).trim()
sh "git -C $tmpDir config core.sparsecheckout false && git -C $tmpDir fetch $remoteName --prune"
sh "git -C $tmpDir checkout master && git -C $tmpDir pull"
// Get branches excluding master and develop
def branches = sh(script: "git -C $tmpDir branch -r", returnStdout: true).trim().split("\n")
def branchesToProcess = branches.findAll { !it.contains("master") && !it.contains("develop") }.collect { it.trim() }
echo "\nAnalyzing ${branchesToProcess.size()} branches...\n"
// Process each branch in isolation
def branchSummary = []
def counts = [merged: 0, stale: 0, active: 0]
for (int i = 0; i < branchesToProcess.size(); i++) {
def branch = branchesToProcess[i]
echo branch
dir("branch-analysis-${i}") {
try {
def info = analyzeBranch(branch, days, tmpDir)
if (info) {
branchSummary.add(info)
if (info.isMerged) {
counts.merged++
tagAndNotify(branch, "merged", info, updateRemote, channel, repoOwner, repoName, remoteName, tmpDir)
} else if (info.isStale) {
counts.stale++
tagAndNotify(branch, "stale", info, updateRemote, channel, repoOwner, repoName, remoteName, tmpDir)
} else {
counts.active++
}
}
} finally {
deleteDir()
}
}
}
// Print summary
printSummary(branchSummary, days, counts, updateRemote)
sh "rm -rf $tmpDir"
echo "Stale branch cleanup completed. Releasing lock for $lockName"
}
}
/**
* Analyzes a branch to determine its age, author, and status (merged/stale/active)
*
* @param branch Branch name to analyze
* @param staleThresholdDays Number of days to consider a branch stale
* @param tmpDir Temporary directory containing the git repository
* @return Map containing branch information or null if analysis fails
*/
def analyzeBranch(branch, staleThresholdDays, tmpDir) {
try {
echo " Analyzing: $branch"
// Get last commit date (try unique commits first, fallback to branch tip)
def lastDateCmd = "git --no-pager -C $tmpDir log master..$branch -1 --format='%cd' --date=format:'%a %b %d %H:%M:%S %Y %z' 2>/dev/null || " +
"git --no-pager -C $tmpDir log $branch -1 --format='%cd' --date=format:'%a %b %d %H:%M:%S %Y %z' 2>/dev/null || echo ''"
def lastCommitDate = sh(script: lastDateCmd, returnStdout: true).trim()
if (!lastCommitDate) {
echo " ⚠️ No commit date found"
return null
}
// Calculate last commit age
def dateFormatter = new java.text.SimpleDateFormat("E MMM d HH:mm:ss yyyy z")
def lastParsed = dateFormatter.parse(lastCommitDate)
def now = new Date()
long lastAgeMillis = now.time - lastParsed.time
int lastAgeDays = (int) (lastAgeMillis / 86400000)
int lastAgeHours = (int) ((lastAgeMillis % 86400000) / 3600000)
int lastAgeMinutes = (int) ((lastAgeMillis % 3600000) / 60000)
// Get first commit date on branch (diverged from master)
def firstDateCmd = "git --no-pager -C $tmpDir log master..$branch --reverse -1 --format='%cd' --date=format:'%a %b %d %H:%M:%S %Y %z' 2>/dev/null || echo ''"
def firstCommitDate = sh(script: firstDateCmd, returnStdout: true).trim()
int firstAgeDays = 0, firstAgeHours = 0, firstAgeMinutes = 0
if (firstCommitDate) {
def firstParsed = dateFormatter.parse(firstCommitDate)
long firstAgeMillis = now.time - firstParsed.time
firstAgeDays = (int) (firstAgeMillis / 86400000)
firstAgeHours = (int) ((firstAgeMillis % 86400000) / 3600000)
firstAgeMinutes = (int) ((firstAgeMillis % 3600000) / 60000)
}
// Get author (first commit on branch, or last commit if merged)
def authorCmd = "git --no-pager -C $tmpDir log master..$branch --reverse --pretty=format:'%an' 2>/dev/null | head -1 || " +
"git --no-pager -C $tmpDir log $branch -1 --pretty=format:'%an' 2>/dev/null || echo 'Unknown'"
def author = sh(script: authorCmd, returnStdout: true).trim()
// Check status
def commitsAhead = sh(script: "git --no-pager -C $tmpDir rev-list master..$branch 2>/dev/null || echo ''", returnStdout: true).trim()
boolean isMerged = !commitsAhead
boolean isStale = false
int ahead = 0, behind = 0
if (!isMerged) {
def recentLogs = sh(script: "git --no-pager -C $tmpDir log master..$branch --since=${staleThresholdDays}.days.ago --oneline 2>/dev/null || echo ''", returnStdout: true).trim()
isStale = !recentLogs
ahead = commitsAhead.split('\n').size()
def commitsBehind = sh(script: "git --no-pager -C $tmpDir rev-list $branch..master 2>/dev/null || echo ''", returnStdout: true).trim()
behind = commitsBehind ? commitsBehind.split('\n').size() : 0
}
def status = isMerged ? "MERGED" : (isStale ? "STALE" : "ACTIVE")
def branchAgeInfo = firstAgeDays > 0 ? " (branch: ${firstAgeDays}d old)" : ""
echo " ✓ ${lastAgeDays}d ${lastAgeHours}h by $author - $status" + (!isMerged ? " (+$ahead/-$behind)" : "") + branchAgeInfo
return [name: branch, author: author,
ageInDays: lastAgeDays, ageInHours: lastAgeHours, ageInMinutes: lastAgeMinutes,
branchAgeInDays: firstAgeDays, branchAgeInHours: firstAgeHours, branchAgeInMinutes: firstAgeMinutes,
isMerged: isMerged, isStale: isStale, ahead: ahead, behind: behind]
} catch (Exception e) {
echo " ✗ Error: ${e.message}"
return null
}
}
/**
* Tags a branch and sends Slack notification before optional deletion
*
* @param branch Branch name to tag
* @param type Type of branch ('merged' or 'stale')
* @param info Branch information map from analyzeBranch
* @param updateRemote If true, actually push tag and delete branch
* @param channel Slack channel for notifications
* @param repoOwner Repository owner (e.g., 'group')
* @param repoName Repository name
* @param remoteName Git remote name (usually 'origin')
* @param tmpDir Temporary directory containing the git repository
*/
def tagAndNotify(branch, type, info, updateRemote, channel, repoOwner, repoName, remoteName, tmpDir) {
def branchName = branch.replaceAll(/^[^\/]+\//, "")
def age = formatAge(info.ageInDays, info.ageInHours, info.ageInMinutes)
def tagName = "$type/$branchName"
// Tag locally
sh "git -C $tmpDir checkout $branchName && git -C $tmpDir tag $tagName"
// Only push tag, send notification, and delete branch if updateRemote is true
if (updateRemote) {
sh "git -C $tmpDir push $remoteName $tagName"
// Build Bitbucket URL pointing to the tag (not the branch, since it will be deleted)
def tagUrl = "https://bitbucket.org/$repoOwner/$repoName/commits/tag/$tagName"
// Build and send Slack message with tag link
def msg = type == "merged" ?
"`[Stale-Bot]` <$tagUrl|$tagName>\nOriginal branch: `$branch`\nAuthor: `${info.author}`\nBranch fully merged to master. Last commit: `$age` ago → marked merged and deleted." :
"`[Stale-Bot]` <$tagUrl|$tagName>\nOriginal branch: `$branch`\nAuthor: `${info.author}` | Ahead: `${info.ahead}` | Behind: `${info.behind}`\nLast commit: `$age` ago → marked stale and deleted."
slackSend(channel: channel, color: type == "merged" ? "#28a745" : "#063773", message: msg)
// Delete branch
sh "git -C $tmpDir push $remoteName --delete $branchName"
}
sh "git -C $tmpDir checkout master"
}
/**
* Formats age in human-readable format
*
* @param days Number of days
* @param hours Number of hours
* @param minutes Number of minutes
* @return Formatted string (e.g., "5d 3h", "2h 15m", "45m")
*/
def formatAge(days, hours, minutes) {
days > 0 ? "${days}d ${hours}h" : hours > 0 ? "${hours}h ${minutes}m" : "${minutes}m"
}
/**
* Prints comprehensive summary report of branch analysis
*
* @param branchSummary List of branch information maps
* @param days Stale threshold in days
* @param counts Map with counts of merged, stale, and active branches
* @param updateRemote Whether deletion mode was enabled
*/
def printSummary(branchSummary, days, counts, updateRemote) {
if (branchSummary.isEmpty()) {
echo "\n${'=' * 120}\nSTALE BOT SUMMARY: No branches to report\n${'=' * 120}\n"
return
}
// Sort by age (oldest first) - manual bubble sort for CPS compatibility
for (int i = 0; i < branchSummary.size() - 1; i++) {
for (int j = 0; j < branchSummary.size() - i - 1; j++) {
if (branchSummary[j].ageInDays < branchSummary[j + 1].ageInDays) {
def temp = branchSummary[j]
branchSummary[j] = branchSummary[j + 1]
branchSummary[j + 1] = temp
}
}
}
def r = new StringBuilder("\n${'=' * 120}\nSTALE BOT SUMMARY REPORT\n${'=' * 120}\n\n")
r.append("Configuration:\n")
r.append(" - Stale threshold: ${days} days\n")
r.append(" - Mode: ${updateRemote ? 'DELETE (branches deleted)' : 'DRY-RUN (no changes)'}\n\n")
r.append("Results:\n")
r.append(" - Total analyzed: ${branchSummary.size()}\n")
r.append(" - Merged: ${counts.merged} ${updateRemote ? '(deleted)' : '(would delete)'}\n")
r.append(" - Stale: ${counts.stale} ${updateRemote ? '(deleted)' : '(would delete)'}\n")
r.append(" - Active: ${counts.active} (kept)\n\n")
r.append("Branch Details:\n\n")
r.append(String.format("%-40s %-15s %-15s %-15s %-10s %s\n", "BRANCH", "AUTHOR", "LAST COMMIT", "BRANCH AGE", "STATUS", "AHEAD/BEHIND"))
r.append("${'-' * 120}\n")
for (int i = 0; i < branchSummary.size(); i++) {
def b = branchSummary[i]
def name = b.name.length() > 38 ? b.name[0..34] + "..." : b.name
def author = b.author.length() > 13 ? b.author[0..9] + "..." : b.author
def lastAge = formatAge(b.ageInDays, b.ageInHours, b.ageInMinutes)
def branchAge = b.branchAgeInDays > 0 ? formatAge(b.branchAgeInDays, b.branchAgeInHours, b.branchAgeInMinutes) : "-"
def status = b.isMerged ? "MERGED" : (b.isStale ? "STALE" : "ACTIVE")
def delta = b.isMerged ? "merged" : "+${b.ahead}/-${b.behind}"
r.append(String.format("%-40s %-15s %-15s %-15s %-10s %s\n", name, author, lastAge, branchAge, status, delta))
}
r.append("${'-' * 120}\n\n")
// Group by status
def merged = [], stale = [], active = []
for (int i = 0; i < branchSummary.size(); i++) {
def b = branchSummary[i]
if (b.isMerged) merged.add(b)
else if (b.isStale) stale.add(b)
else active.add(b)
}
// Merged branches
if (!merged.isEmpty()) {
r.append("Merged Branches (${merged.size()}):\n")
for (int i = 0; i < merged.size(); i++) {
def b = merged[i]
def branchAge = b.branchAgeInDays > 0 ? ", branch created ${formatAge(b.branchAgeInDays, b.branchAgeInHours, b.branchAgeInMinutes)} ago" : ""
r.append(" - ${b.name} (by ${b.author}, last commit ${formatAge(b.ageInDays, b.ageInHours, b.ageInMinutes)} ago${branchAge})\n")
}
r.append("\n")
}
// Stale branches
if (!stale.isEmpty()) {
r.append("Stale Branches (${stale.size()}):\n")
for (int i = 0; i < stale.size(); i++) {
def b = stale[i]
def branchAge = b.branchAgeInDays > 0 ? ", branch created ${formatAge(b.branchAgeInDays, b.branchAgeInHours, b.branchAgeInMinutes)} ago" : ""
r.append(" - ${b.name} (by ${b.author}, last commit ${formatAge(b.ageInDays, b.ageInHours, b.ageInMinutes)} ago, +${b.ahead}/-${b.behind}${branchAge})\n")
}
r.append("\n")
}
// Active branches
if (!active.isEmpty()) {
r.append("Active Branches (${active.size()}):\n")
for (int i = 0; i < active.size(); i++) {
def b = active[i]
def branchAge = b.branchAgeInDays > 0 ? ", branch created ${formatAge(b.branchAgeInDays, b.branchAgeInHours, b.branchAgeInMinutes)} ago" : ""
r.append(" - ${b.name} (by ${b.author}, last commit ${formatAge(b.ageInDays, b.ageInHours, b.ageInMinutes)} ago, +${b.ahead}/-${b.behind}${branchAge})\n")
}
r.append("\n")
}
r.append("${'=' * 120}\n")
echo r.toString()
}
/**
* Helper function to get Git URL in SSH format
* Converts HTTPS Bitbucket URLs to SSH format
*
* @return Git URL in SSH format
*/
def gitUrl() {
return env.GIT_URL.replaceFirst(/^https:\/\/bitbucket.org\//, "git@bitbucket.org:")
}
// Make functions available for use
return this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment