This workflow corresponds to n8n.io template #16068 — 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 →
{
"id": "8aej0j0MG4P2gkaV",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Daily GitHub PR Review Digest",
"tags": [],
"nodes": [
{
"id": "eea5de0e-51fb-46d0-825f-52e0634c10b2",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
704,
928
],
"parameters": {
"width": 528,
"height": 784,
"content": "# Daily GitHub PR Review Digest\n\n\n## How it works\n\n1. Workflow triggers every weekday at 9 AM and loads configuration for repositories, staleness threshold, Slack channel, and draft filtering.\n2. Repository list is split into individual items and open pull requests are fetched from GitHub for each repo.\n3. PR data is flattened and enriched by fetching review information for each PR to calculate review lag.\n4. All PRs are ranked by review lag duration and compiled into a Slack-formatted digest message.\n5. The workflow conditionally posts the digest to Slack only if stale PRs exist.\n\n\n## Setup steps\n\n- [ ] Configure the 'Config' node with: repos array (e.g., ['owner/repo1', 'owner/repo2']), stale_days threshold, slack_channel name, and exclude_drafts boolean\n- [ ] Add GitHub API credentials to 'Fetch Open PRs' and 'Fetch PR Reviews' HTTP nodes (Personal Access Token with 'repos' scope)\n- [ ] Configure the 'Post Digest to Slack' node with valid Slack webhook or bot token and target channel\n- [ ] Verify schedule trigger is set to weekday mornings at 9 AM in your target timezone\n\n\n## Customization\n\nModify the 'Rank and Build Digest' code node to adjust PR sorting criteria (e.g., by comment count, priority labels) or change Slack message formatting. Update stale_days in Config to change what constitutes a 'stale' PR."
},
"typeVersion": 1
},
{
"id": "946b071a-4c49-4238-8479-15e1680d967e",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
1312,
928
],
"parameters": {
"color": 7,
"width": 672,
"height": 320,
"content": "## Trigger and configuration setup\n\nInitiates the workflow on schedule and loads all configuration parameters needed downstream (repos list, thresholds, Slack channel, filter options)"
},
"typeVersion": 1
},
{
"id": "4969ca72-71c1-4528-9fd8-3682fcb2a9b0",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
2032,
928
],
"parameters": {
"color": 7,
"width": 432,
"height": 320,
"content": "## Fetch and flatten PR data\n\nRetrieves open pull requests from GitHub API and normalizes the nested response structure into individual PR records for processing"
},
"typeVersion": 1
},
{
"id": "a91002db-49ac-4c08-a0b5-3238d78240d6",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
2512,
928
],
"parameters": {
"color": 7,
"width": 432,
"height": 320,
"content": "## Enrich with review metrics\n\nFetches review history for each PR and calculates review lag duration to identify stale pull requests awaiting feedback"
},
"typeVersion": 1
},
{
"id": "2eedb9f6-30a1-4505-b9a5-7695173b44f1",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
2992,
928
],
"parameters": {
"color": 7,
"width": 432,
"height": 320,
"content": "## Rank, filter and format digest\n\nAggregates all enriched PRs, ranks them by review lag severity, and constructs a Slack-ready digest message for delivery"
},
"typeVersion": 1
},
{
"id": "bcae82b7-47b9-41ec-b9f6-37650bf793f4",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
3520,
816
],
"parameters": {
"color": 7,
"width": 304,
"height": 336,
"content": "## Post to Slack\n\nSends the compiled PR review lag digest to the configured Slack channel if stale PRs were found"
},
"typeVersion": 1
},
{
"id": "cfc8e7b5-e017-4e29-a6fc-a5fa99306b91",
"name": "When Weekday at 9am",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
1360,
1088
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * 1-5"
}
]
}
},
"typeVersion": 1.3
},
{
"id": "1031efb2-1fc5-4369-b62a-5cc7ff510618",
"name": "Set PR Monitor Config",
"type": "n8n-nodes-base.set",
"position": [
1600,
1088
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "cfg-001",
"name": "repos",
"type": "array",
"value": "=[\"your-org/repo-one\", \"your-org/repo-two\", \"your-org/repo-three\"]"
},
{
"id": "cfg-002",
"name": "stale_days",
"type": "number",
"value": 5
},
{
"id": "cfg-003",
"name": "slack_channel",
"type": "string",
"value": "#eng-daily"
},
{
"id": "cfg-004",
"name": "exclude_drafts",
"type": "boolean",
"value": false
},
{
"id": "cfg-005",
"name": "github_api_base",
"type": "string",
"value": "https://api.github.com"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "2df1a357-f6ac-41f3-96d6-39c5c607f942",
"name": "Expand Repos to Items",
"type": "n8n-nodes-base.code",
"position": [
1840,
1088
],
"parameters": {
"jsCode": "// Expand each repo into a separate item so the loop processes them one by one\nconst config = $input.first().json;\nconst repos = config.repos || [];\n\nreturn repos.map(repo => ({\n json: {\n repo,\n stale_days: config.stale_days,\n slack_channel: config.slack_channel,\n exclude_drafts: config.exclude_drafts,\n github_api_base: config.github_api_base\n }\n}));"
},
"typeVersion": 2
},
{
"id": "851b16eb-3d94-4a54-912d-80e4ebd30c04",
"name": "Fetch GitHub Open PRs",
"type": "n8n-nodes-base.httpRequest",
"position": [
2080,
1088
],
"parameters": {
"url": "=/repos//pulls",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"queryParameters": {
"parameters": [
{
"name": "state",
"value": "open"
},
{
"name": "per_page",
"value": "100"
},
{
"name": "sort",
"value": "updated"
},
{
"name": "direction",
"value": "desc"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "f3cd94d3-a46d-4592-af29-88aa1cdf52e1",
"name": "Parse PR Response Data",
"type": "n8n-nodes-base.code",
"position": [
2320,
1088
],
"parameters": {
"jsCode": "// Flatten the array response from GitHub into individual PR items\n// Pass through repo metadata for downstream use\nconst item = $input.first();\nconst prs = Array.isArray(item.json) ? item.json : [];\nconst repo = $('Expand Repos to Items').item.json.repo;\nconst stale_days = $('Expand Repos to Items').item.json.stale_days;\nconst exclude_drafts = $('Expand Repos to Items').item.json.exclude_drafts;\nconst github_api_base = $('Expand Repos to Items').item.json.github_api_base;\n\nif (prs.length === 0) return [];\n\nreturn prs\n .filter(pr => {\n if (exclude_drafts && pr.draft) return false;\n return true;\n })\n .map(pr => ({\n json: {\n repo,\n stale_days,\n github_api_base,\n pr_number: pr.number,\n pr_title: pr.title,\n pr_url: pr.html_url,\n pr_author: pr.user?.login || 'unknown',\n pr_draft: pr.draft || false,\n pr_created_at: pr.created_at,\n pr_updated_at: pr.updated_at,\n pr_base_branch: pr.base?.ref || 'unknown',\n pr_head_branch: pr.head?.ref || 'unknown',\n requested_reviewers: (pr.requested_reviewers || []).map(r => r.login),\n requested_teams: (pr.requested_teams || []).map(t => t.slug),\n labels: (pr.labels || []).map(l => l.name),\n mergeable_state: pr.mergeable_state || null\n }\n }));"
},
"typeVersion": 2
},
{
"id": "ff382665-0fd9-43ab-bbfa-e15de23a5dc0",
"name": "Fetch PR Review Data",
"type": "n8n-nodes-base.httpRequest",
"position": [
2560,
1088
],
"parameters": {
"url": "=/repos//pulls//reviews",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"queryParameters": {
"parameters": [
{
"name": "per_page",
"value": "100"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "4402c9ab-4ee1-46a1-9f83-4fc00090bb1d",
"name": "Calculate Review Lag",
"type": "n8n-nodes-base.code",
"position": [
2800,
1088
],
"parameters": {
"jsCode": "// Enrich each PR with review data\n// Calculate: review_lag_hours, review_status, blocking_reviewers\n\nconst pr = $('Parse PR Response Data').item.json;\nconst reviews = Array.isArray($input.first().json) ? $input.first().json : [];\n\nconst now = new Date();\nconst created = new Date(pr.pr_created_at);\nconst updated = new Date(pr.pr_updated_at);\n\n// Age in hours since PR was opened\nconst age_hours = Math.floor((now - created) / 3600000);\nconst age_days = Math.floor(age_hours / 24);\n\n// Review lag: hours since the most recent review activity\n// If no reviews at all, lag = age (no one has touched it)\nlet last_review_at = null;\nlet approved_by = [];\nlet changes_requested_by = [];\nlet commented_by = [];\n\nfor (const review of reviews) {\n const submitted = new Date(review.submitted_at);\n if (!last_review_at || submitted > last_review_at) {\n last_review_at = submitted;\n }\n if (review.state === 'APPROVED') approved_by.push(review.user?.login);\n if (review.state === 'CHANGES_REQUESTED') changes_requested_by.push(review.user?.login);\n if (review.state === 'COMMENTED') commented_by.push(review.user?.login);\n}\n\n// Deduplicate \u2014 keep only the latest state per reviewer\nconst reviewer_states = {};\nfor (const review of reviews.sort((a, b) => new Date(a.submitted_at) - new Date(b.submitted_at))) {\n reviewer_states[review.user?.login] = review.state;\n}\n\napproved_by = Object.entries(reviewer_states).filter(([, s]) => s === 'APPROVED').map(([u]) => u);\nchanges_requested_by = Object.entries(reviewer_states).filter(([, s]) => s === 'CHANGES_REQUESTED').map(([u]) => u);\n\nconst review_lag_hours = last_review_at\n ? Math.floor((now - last_review_at) / 3600000)\n : age_hours;\n\n// Determine status\nlet status;\nif (pr.pr_draft) {\n status = 'draft';\n} else if (changes_requested_by.length > 0) {\n status = 'changes_requested';\n} else if (approved_by.length > 0 && pr.requested_reviewers.length === 0) {\n status = 'approved';\n} else if (reviews.length === 0 && age_hours > 0) {\n status = 'needs_review';\n} else if (pr.requested_reviewers.length > 0) {\n status = 'needs_review';\n} else {\n status = 'in_review';\n}\n\n// Stale: no review activity for stale_days\nconst is_stale = !pr.pr_draft && review_lag_hours > (pr.stale_days * 24);\n\n// Who is blocking: requested reviewers who haven't reviewed yet\nconst reviewed_logins = new Set(Object.keys(reviewer_states));\nconst blocking = pr.requested_reviewers.filter(r => !reviewed_logins.has(r));\n\nreturn [{\n json: {\n ...pr,\n age_hours,\n age_days,\n review_lag_hours,\n last_review_at: last_review_at ? last_review_at.toISOString() : null,\n approved_by,\n changes_requested_by,\n status,\n is_stale,\n blocking_reviewers: blocking,\n review_count: reviews.length\n }\n}];"
},
"typeVersion": 2
},
{
"id": "4c4830e4-0667-4bed-8ba7-24205bc5f903",
"name": "Build Slack Digest",
"type": "n8n-nodes-base.code",
"position": [
3040,
1088
],
"parameters": {
"jsCode": "// Aggregate all enriched PRs, rank by review_lag_hours, build Slack digest\n\nconst all = $input.all().map(i => i.json);\n\nif (all.length === 0) {\n return [{ json: { skip: true, message: 'No open PRs across watched repos.' } }];\n}\n\n// Sort by review lag descending (most neglected first)\nall.sort((a, b) => b.review_lag_hours - a.review_lag_hours);\n\n// Status emoji map\nconst statusIcon = {\n needs_review: '\ud83d\udc40',\n changes_requested: '\ud83d\udd04',\n approved: '\u2705',\n in_review: '\ud83d\udcac',\n draft: '\ud83d\udcdd'\n};\n\n// Format age / lag helper\nfunction formatHours(h) {\n if (h < 1) return '<1h';\n if (h < 24) return `${h}h`;\n return `${Math.floor(h / 24)}d ${h % 24}h`;\n}\n\n// Truncate title\nfunction trunc(s, n) {\n return s.length <= n ? s : s.slice(0, n - 1) + '\u2026';\n}\n\n// Group by author\nconst by_author = {};\nfor (const pr of all) {\n if (!by_author[pr.pr_author]) by_author[pr.pr_author] = [];\n by_author[pr.pr_author].push(pr);\n}\n\n// Sort authors by their worst (highest lag) PR\nconst sorted_authors = Object.entries(by_author).sort((a, b) => {\n const worstA = Math.max(...a[1].map(p => p.review_lag_hours));\n const worstB = Math.max(...b[1].map(p => p.review_lag_hours));\n return worstB - worstA;\n});\n\nconst today = new Date().toLocaleDateString('en-GB', {\n weekday: 'short', day: 'numeric', month: 'short'\n});\n\nconst stale_count = all.filter(p => p.is_stale && !p.pr_draft).length;\nconst needs_review_count = all.filter(p => p.status === 'needs_review').length;\nconst draft_count = all.filter(p => p.pr_draft).length;\nconst repos_covered = [...new Set(all.map(p => p.repo))];\n\n// Header\nconst lines = [\n `*PR Digest \u00b7 ${today}*`,\n `${repos_covered.length} repo${repos_covered.length > 1 ? 's' : ''} \u00b7 ${all.length} open \u00b7 ${needs_review_count} need review \u00b7 ${stale_count} stale${draft_count > 0 ? ` \u00b7 ${draft_count} draft` : ''}`,\n ''\n];\n\n// Per-author sections\nfor (const [author, prs] of sorted_authors) {\n lines.push(`*@${author}*`);\n for (const pr of prs) {\n const icon = statusIcon[pr.status] || '\u2753';\n const stale_marker = pr.is_stale ? ' \u26a0\ufe0f' : '';\n const title = trunc(pr.pr_title, 55);\n const repo_short = pr.repo.split('/')[1];\n\n let detail = `age ${formatHours(pr.age_hours)} \u00b7 lag ${formatHours(pr.review_lag_hours)}`;\n\n if (pr.blocking_reviewers.length > 0) {\n detail += ` \u00b7 waiting on ${pr.blocking_reviewers.map(r => `@${r}`).join(', ')}`;\n } else if (pr.changes_requested_by.length > 0) {\n detail += ` \u00b7 changes by ${pr.changes_requested_by.map(r => `@${r}`).join(', ')}`;\n } else if (pr.approved_by.length > 0) {\n detail += ` \u00b7 approved by ${pr.approved_by.map(r => `@${r}`).join(', ')}`;\n }\n\n lines.push(`${icon}${stale_marker} <${pr.pr_url}|[${repo_short}] ${title}>`);\n lines.push(` \u21b3 ${detail}`);\n }\n lines.push('');\n}\n\n// Footer hint\nlines.push(`_Ranked by review lag \u00b7 stale = no activity >${all[0]?.stale_days || 5}d_`);\n\nconst slack_channel = all[0]?.slack_channel || '#eng-daily';\n\nreturn [{\n json: {\n skip: false,\n slack_channel,\n slack_message: lines.join('\n'),\n pr_count: all.length,\n stale_count,\n needs_review_count\n }\n}];"
},
"typeVersion": 2
},
{
"id": "dcaf5d62-94e7-49a3-91d4-530aca987916",
"name": "Check PRs Found",
"type": "n8n-nodes-base.if",
"position": [
3280,
1088
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "skip-check",
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
},
"leftValue": "="
}
]
}
},
"typeVersion": 2.3
},
{
"id": "e1df5e15-2412-485c-b816-3516194f8523",
"name": "Send Digest to Slack",
"type": "n8n-nodes-base.slack",
"position": [
3568,
992
],
"parameters": {
"text": "=",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "="
},
"otherOptions": {
"unfurl_links": false,
"unfurl_media": false,
"includeLinkToWorkflow": false
},
"authentication": "oAuth2"
},
"typeVersion": 2.4
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "12318c27-3f67-4f99-a057-2b2f06e0653e",
"connections": {
"Check PRs Found": {
"main": [
[
{
"node": "Send Digest to Slack",
"type": "main",
"index": 0
}
]
]
},
"Build Slack Digest": {
"main": [
[
{
"node": "Check PRs Found",
"type": "main",
"index": 0
}
]
]
},
"When Weekday at 9am": {
"main": [
[
{
"node": "Set PR Monitor Config",
"type": "main",
"index": 0
}
]
]
},
"Calculate Review Lag": {
"main": [
[
{
"node": "Build Slack Digest",
"type": "main",
"index": 0
}
]
]
},
"Fetch PR Review Data": {
"main": [
[
{
"node": "Calculate Review Lag",
"type": "main",
"index": 0
}
]
]
},
"Expand Repos to Items": {
"main": [
[
{
"node": "Fetch GitHub Open PRs",
"type": "main",
"index": 0
}
]
]
},
"Fetch GitHub Open PRs": {
"main": [
[
{
"node": "Parse PR Response Data",
"type": "main",
"index": 0
}
]
]
},
"Set PR Monitor Config": {
"main": [
[
{
"node": "Expand Repos to Items",
"type": "main",
"index": 0
}
]
]
},
"Parse PR Response Data": {
"main": [
[
{
"node": "Fetch PR Review Data",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs every weekday at 9 AM, checks open pull requests across configured GitHub repositories, calculates how long each PR has been waiting on review activity, and posts a ranked review-lag digest to a chosen Slack channel. Runs on a weekday schedule at 9:00 AM and…
Source: https://n8n.io/workflows/16068/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
debug. Uses httpRequest, slack, redis, mailgun. Scheduled trigger; 60 nodes.
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
Import Productboard Notes Companies And Features Into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.
Import Productboard Notes, Companies and Features into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.
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