{
  "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
          }
        ],
        []
      ]
    }
  }
}