AutomationFlowsSlack & Telegram › Report and Clean Up Stale Github Branches with Slack Notifications

Report and Clean Up Stale Github Branches with Slack Notifications

ByAdriaan van Niekerk @adriaan on n8n.io

This workflow runs weekly (or manually) to scan GitHub repositories for merged-but-not-deleted and stale branches, then posts a cleanup report to Slack and can optionally auto-delete a capped number of branches. Runs on a weekly schedule or when you manually execute the…

Cron / scheduled trigger★★★★☆ complexity20 nodesHTTP RequestSlack
Slack & Telegram Trigger: Cron / scheduled Nodes: 20 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16220 — we link there as the canonical source.

This workflow follows the HTTP Request → Slack recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "OcCC2z3Tv7HZ1iqp",
  "name": "Weekly Git Branch Cleanup Report \u2014 Find Stale & Merged Branches",
  "tags": [],
  "nodes": [
    {
      "id": "24438dea-ab4c-40c8-bfc2-21745a2450ca",
      "name": "Main \u2014 Branch Cleanup Reporter",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1312,
        -112
      ],
      "parameters": {
        "width": 360,
        "height": 716,
        "content": "## \ud83e\uddf9 Stale Branch Cleanup Reporter\n\n### \ud83d\udc64 Who's it for\nEngineering leads and DevOps teams drowning in old Git branches. Reports merged-but-not-deleted and inactive branches so you can reclaim repository clarity.\n\n### \u2699\ufe0f What it does\n1. Runs on a weekly schedule (or on-demand via webhook).\n2. Reads your repo list from config.\n3. Fetches all branches and recently merged PRs per repo.\n4. Classifies branches as merged (PR merged, branch still alive) or stale (no commits for N days).\n5. Skips protected branches (`main`, `master`, `develop`, `release/*`).\n6. Posts a Slack report listing the top candidates.\n7. In **report-only mode** (default), no branches are deleted.\n8. If you enable auto-delete (`reportOnly: false`), stale and merged branches are removed with a safety cap.\n\n### \ud83d\udee0\ufe0f How to set up\n\u2022 Attach **GitHub** (PAT with repo delete) and **Slack** credentials.\n\u2022 Edit **Configuration**: repo list, stale age, protected branches.\n\u2022 Start with `reportOnly: true` \u2014 only switch to auto-delete after reviewing reports.\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "68797396-5254-4cc8-96be-b1f8895b347e",
      "name": "Section \u2014 Collect",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -608,
        304
      ],
      "parameters": {
        "width": 852,
        "height": 224,
        "content": "## \ud83d\udce5 **Collect**"
      },
      "typeVersion": 1
    },
    {
      "id": "2117a2e4-ff87-43f0-9e76-a40230b80bdf",
      "name": "Section \u2014 Analyze",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        256,
        304
      ],
      "parameters": {
        "width": 452,
        "height": 224,
        "content": "## \ud83d\udd0d **Analyze**"
      },
      "typeVersion": 1
    },
    {
      "id": "a273595c-0624-4bbd-ab00-cb6196978061",
      "name": "Section \u2014 Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        736,
        448
      ],
      "parameters": {
        "width": 484,
        "height": 224,
        "content": "## \ud83d\udce3 **Report**"
      },
      "typeVersion": 1
    },
    {
      "id": "e03e2bb8-8fcc-4354-86a0-3e809e9d66d2",
      "name": "Section \u2014 Delete",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        736,
        176
      ],
      "parameters": {
        "width": 916,
        "height": 256,
        "content": "## \ud83d\uddd1\ufe0f **Delete** (opt-in)"
      },
      "typeVersion": 1
    },
    {
      "id": "d88d459c-6279-4dd1-b2c9-6c40ca026d8f",
      "name": "Credentials & Safety Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1312,
        624
      ],
      "parameters": {
        "width": 364,
        "height": 156,
        "content": "\ud83d\udd11 **Credentials needed**\n\u2022 GitHub PAT: `repo` (delete requires write)\n\u2022 Slack Bot (`chat:write`)\n\n\ud83d\udee1\ufe0f **Safety**\n`reportOnly: true` by default \u2192 no deletions happen unless you opt in."
      },
      "typeVersion": 1
    },
    {
      "id": "53856cfb-aaf9-4b5d-b349-1bce043023a7",
      "name": "Weekly Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -800,
        256
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks"
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "01f5f903-98a3-4d90-bbc9-f8af494eaf35",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -576,
        352
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "repoOwner",
              "name": "repoOwner",
              "type": "string",
              "value": "your-org-or-username"
            },
            {
              "id": "scanRepos",
              "name": "scanRepos",
              "type": "string",
              "value": "repo-one,repo-two,repo-three"
            },
            {
              "id": "staleDays",
              "name": "staleDays",
              "type": "number",
              "value": 80
            },
            {
              "id": "protectedBranches",
              "name": "protectedBranches",
              "type": "string",
              "value": "main,master,develop,release/*"
            },
            {
              "id": "reportOnly",
              "name": "reportOnly",
              "type": "boolean",
              "value": true
            },
            {
              "id": "slackChannel",
              "name": "slackChannel",
              "type": "string",
              "value": "#devops-cleanup"
            },
            {
              "id": "maxBranchesToDelete",
              "name": "maxBranchesToDelete",
              "type": "number",
              "value": 20
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "0064c722-2081-4d55-a7bf-4ce5a46a5e57",
      "name": "Expand Repo List",
      "type": "n8n-nodes-base.code",
      "position": [
        -352,
        352
      ],
      "parameters": {
        "jsCode": "const config = $('Configuration').item.json;\nconst repos = config.scanRepos.split(',').map(r => r.trim());\nreturn repos.map(repo => ({ repoName: repo, repoOwner: config.repoOwner }));"
      },
      "typeVersion": 2
    },
    {
      "id": "de704c82-bfbd-4853-b11f-8dccaf12e734",
      "name": "Fetch All Branches",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -128,
        352
      ],
      "parameters": {
        "url": "https://api.github.com/graphql",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "query",
              "value": "=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { refs(refPrefix: \"refs/heads/\", first: 100) { nodes { name target { ... on Commit { committedDate } } } } } }"
            },
            {
              "name": "variables",
              "value": "={\"owner\":\"{{ $json.repoOwner }}\",\"name\":\"{{ $json.repoName }}\"}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            }
          ]
        },
        "nodeCredentialType": "githubApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        },
        "githubOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4,
      "alwaysOutputData": false
    },
    {
      "id": "cb62f223-8526-4158-aec3-7de0ee5b8070",
      "name": "Fetch Recently Merged PRs",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        96,
        352
      ],
      "parameters": {
        "url": "=https://api.github.com/repos/{{ $('Expand Repo List').item.json.repoOwner }}/{{ $('Expand Repo List').item.json.repoName }}/pulls?state=closed&per_page=100",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        },
        "githubOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "e5160bf5-6ad0-475e-943c-3a684d549150",
      "name": "Classify Branches \u2014 Merged vs Stale",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        352
      ],
      "parameters": {
        "jsCode": "const gqlResponse = $('Fetch All Branches').item.json;\nconst mergedPRsRaw = $('Fetch Recently Merged PRs').item.json;\nconst config = $('Configuration').item.json;\n\nconst now = new Date();\nconst staleThreshold = new Date(now - config.staleDays * 24 * 3600 * 1000);\n\nconst protectedPatterns = config.protectedBranches.split(',').map(p => p.trim());\n\nfunction isProtected(branchName) {\n  return protectedPatterns.some(pattern => {\n    if (pattern.includes('*')) {\n      const regex = new RegExp('^' + pattern.replace(/\\*/g, '.*') + '$');\n      return regex.test(branchName);\n    }\n    return pattern === branchName;\n  });\n}\n\n// Build merged branch set from PRs\nconst mergedBranchNames = new Set();\nif (Array.isArray(mergedPRsRaw)) {\n  for (const pr of mergedPRsRaw) {\n    if (pr.merged_at && pr.head && pr.head.ref) {\n      mergedBranchNames.add(pr.head.ref);\n    }\n  }\n}\n\n// Parse GraphQL response: data.repository.refs.nodes\nconst nodes = gqlResponse?.data?.repository?.refs?.nodes || [];\nconst branches = nodes.map(n => ({\n  name: n.name,\n  lastCommitDate: n.target?.committedDate || null\n}));\n\nconst candidates = [];\nlet totalBranches = branches.length;\nlet protectedCount = 0;\n\nfor (const branch of branches) {\n  // Skip invalid branch names (empty, whitespace-only, literal \"-\")\n  if (!branch.name || branch.name.trim() === \"\" || branch.name.trim() === \"-\") continue;\n  if (isProtected(branch.name)) {\n    protectedCount++;\n    continue;\n  }\n  \n  let reason = '';\n  let ageDays = 0;\n  let lastCommitDateStr = 'unknown';\n  \n  if (mergedBranchNames.has(branch.name)) {\n    reason = 'merged';\n    if (branch.lastCommitDate) {\n      const lastActive = new Date(branch.lastCommitDate);\n      ageDays = Math.round((now - lastActive) / 86400000);\n      lastCommitDateStr = lastActive.toISOString().split('T')[0];\n    }\n  } else if (branch.lastCommitDate) {\n    const lastActive = new Date(branch.lastCommitDate);\n    ageDays = Math.round((now - lastActive) / 86400000);\n    lastCommitDateStr = lastActive.toISOString().split('T')[0];\n    \n    if (lastActive < staleThreshold) {\n      reason = 'stale';\n    } else {\n      continue; // active branch, skip\n    }\n  } else {\n    // No commit date available \u2014 treat as potentially stale\n    reason = 'stale';\n    lastCommitDateStr = 'unknown';\n  }\n  \n  candidates.push({\n    branchName: branch.name,\n    lastCommitDate: lastCommitDateStr,\n    ageDays,\n    reason\n  });\n}\n\nconst toDelete = candidates.slice(0, config.maxBranchesToDelete);\n\nreturn {\n  repoName: config.scanRepos.split(',')[0] || 'unknown',\n  repoOwner: config.repoOwner,\n  reportDate: now.toISOString().split('T')[0],\n  totalBranches,\n  protectedCount,\n  staleCandidates: candidates.length,\n  mergedCandidates: candidates.filter(c => c.reason === 'merged').length,\n  staleCandidatesCount: candidates.filter(c => c.reason === 'stale').length,\n  toDelete: toDelete,\n  willDeleteCount: toDelete.length,\n  reportOnly: config.reportOnly\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "51ff2367-64bf-4b70-a6af-e3ad56644f80",
      "name": "Format Cleanup Report",
      "type": "n8n-nodes-base.code",
      "position": [
        800,
        496
      ],
      "parameters": {
        "jsCode": "const data = $('Classify Branches \u2014 Merged vs Stale').item.json;\n\nlet lines = [];\nlines.push(`*\ud83e\uddf9 Weekly Branch Cleanup Report \u2014 ${data.reportDate}*`);\nlines.push('');\nlines.push(`*Repository:* ${data.repoOwner}/${data.repoName}`);\nlines.push(`\u2022 Total branches: ${data.totalBranches}`);\nlines.push(`\u2022 Protected (skipped): ${data.protectedCount}`);\nlines.push(`\u2022 Stale candidates: ${data.staleCandidatesCount} (${$('Configuration').item.json.staleDays}+ days)`);\nlines.push(`\u2022 Merged but not deleted: ${data.mergedCandidates}`);\nlines.push(`\u2022 Ready for cleanup: ${data.willDeleteCount}/${data.staleCandidates + data.mergedCandidates}`);\n\nif (data.reportOnly) {\n  lines.push('');\n  lines.push('\ud83d\udccb *Report-only mode* \u2014 no branches deleted.');\n  lines.push(`Set 'reportOnly' to \\`false\\` in config to enable auto-deletion.`);\n}\n\nif (data.toDelete.length > 0) {\n  lines.push('');\n  lines.push(`*Top ${Math.min(10, data.toDelete.length)} cleanup candidates:*`);\n  for (const b of data.toDelete.slice(0, 10)) {\n    const icon = b.reason === 'merged' ? '\ud83d\udd00' : '\u23f3';\n    lines.push(`  ${icon} \\`${b.branchName}\\` \u2014 ${b.ageDays}d old (${b.reason})`);\n  }\n}\n\nreturn { message: lines.join('\\n') };"
      },
      "typeVersion": 2
    },
    {
      "id": "329e07b5-d368-4bfb-ba7b-7531a9b1a510",
      "name": "Send Report to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1008,
        496
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "otherOptions": {}
      },
      "typeVersion": 2.2
    },
    {
      "id": "c576c39d-e853-4a05-9664-c4effa3281e3",
      "name": "Auto-Delete Enabled?",
      "type": "n8n-nodes-base.if",
      "position": [
        544,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "report-only-check",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $('Configuration').item.json.reportOnly }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "9d51749c-2eef-4a43-aecf-aa5b290f4b89",
      "name": "Split Branches for Deletion",
      "type": "n8n-nodes-base.code",
      "position": [
        768,
        256
      ],
      "parameters": {
        "jsCode": "const candidates = $('Classify Branches \u2014 Merged vs Stale').item.json.toDelete;\n\nreturn candidates.map(b => ({\n  branchName: b.branchName,\n  reason: b.reason,\n  repoOwner: $('Classify Branches \u2014 Merged vs Stale').item.json.repoOwner,\n  repoName: $('Classify Branches \u2014 Merged vs Stale').item.json.repoName\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "e918517a-060e-4929-adbd-988fe45679ee",
      "name": "Delete Single Branch",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "position": [
        992,
        256
      ],
      "parameters": {
        "url": "=https://api.github.com/repos/{{ $json.repoOwner }}/{{ $json.repoName }}/git/refs/heads/{{ $json.branchName }}",
        "method": "DELETE",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi"
      },
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        },
        "githubOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "29ac493a-ce4e-4de0-8b50-09a07cbccba0",
      "name": "Summarize Deletion Results",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        256
      ],
      "parameters": {
        "jsCode": "const deleted = $input.all().filter(i => !i.json.error).length;\nconst failed = $input.all().filter(i => i.json.error).length;\n\nreturn {\n  totalDeleted: deleted,\n  totalFailed: failed,\n  summary: `Deleted ${deleted} branches. ${failed > 0 ? `${failed} failed.` : 'All successful.'}`\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "d00598d0-cd27-4c8d-87a7-00dfeadc82f3",
      "name": "Send Cleanup Summary to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1440,
        256
      ],
      "parameters": {
        "text": "=\u2705 *Branch Cleanup Complete*\n\n{{ $('Summarize Deletion Results').item.json.summary }}\n\n\u2022 Repo: {{ $('Classify Branches \u2014 Merged vs Stale').item.json.repoOwner }}/{{ $('Classify Branches \u2014 Merged vs Stale').item.json.repoName }}\n\u2022 Merged branches deleted: {{ $('Classify Branches \u2014 Merged vs Stale').item.json.mergedCandidates }}\n\u2022 Stale branches deleted: {{ $('Classify Branches \u2014 Merged vs Stale').item.json.staleCandidatesCount }}",
        "otherOptions": {}
      },
      "typeVersion": 2.2
    },
    {
      "id": "6001869f-02f7-468e-83dc-b7ed12aee532",
      "name": "When clicking \u2018Execute workflow\u2019",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -800,
        448
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "b3a91e2c-6582-4695-a992-d231666147d3",
  "nodeGroups": [],
  "connections": {
    "Configuration": {
      "main": [
        [
          {
            "node": "Expand Repo List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand Repo List": {
      "main": [
        [
          {
            "node": "Fetch All Branches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch All Branches": {
      "main": [
        [
          {
            "node": "Fetch Recently Merged PRs",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Auto-Delete Enabled?": {
      "main": [
        [
          {
            "node": "Split Branches for Deletion",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Cleanup Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Delete Single Branch": {
      "main": [
        [
          {
            "node": "Summarize Deletion Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Cleanup Report": {
      "main": [
        [
          {
            "node": "Send Report to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Schedule Trigger": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Recently Merged PRs": {
      "main": [
        [
          {
            "node": "Classify Branches \u2014 Merged vs Stale",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize Deletion Results": {
      "main": [
        [
          {
            "node": "Send Cleanup Summary to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Branches for Deletion": {
      "main": [
        [
          {
            "node": "Delete Single Branch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking \u2018Execute workflow\u2019": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Branches \u2014 Merged vs Stale": {
      "main": [
        [
          {
            "node": "Auto-Delete Enabled?",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs weekly (or manually) to scan GitHub repositories for merged-but-not-deleted and stale branches, then posts a cleanup report to Slack and can optionally auto-delete a capped number of branches. Runs on a weekly schedule or when you manually execute the…

Source: https://n8n.io/workflows/16220/ — original creator credit. Request a take-down →

More Slack & Telegram workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Slack & Telegram

debug. Uses httpRequest, slack, redis, mailgun. Scheduled trigger; 60 nodes.

HTTP Request, Slack, Redis +2
Slack & Telegram

This workflow is an automated employee time tracking and reporting system that monitors weekly work hours via TMetric, then delivers personalized summaries directly to each team member on Slack. It co

HTTP Request, Item Lists, Data Table +1
Slack & Telegram

Import Productboard Notes Companies And Features Into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.

HTTP Request, Snowflake, Slack
Slack & Telegram

Import Productboard Notes, Companies and Features into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.

HTTP Request, Snowflake, Slack
Slack & Telegram

This workflow imports Productboard data into Snowflake, automating data extraction, mapping, and updates for features, companies, and notes. It supports scheduled weekly updates, data cleansing, and S

HTTP Request, Snowflake, Slack