Last active
January 28, 2026 12:11
-
-
Save kibotu/cfed790ccbdcb06ed4aac5f70499b9a3 to your computer and use it in GitHub Desktop.
Stalebot for Groovy Jenkins Pipeline & Bitbucket Cloud
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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