Pull requests don’t get stuck because they’re hard.
They get stuck because everyone forgets due to various reasons.
So instead of relying on memory, I built a small bot using Rapidforge that:
- finds stale PRs
- posts a clean reminder to Slack
- avoids spamming the same alerts
What is RapidForge?
RapidForge is a self hosted platform enabling users to turn small scripts into tools you can actually run and reuse. Its super easy to setup and use.
What we’re building
We’re going to create a small bot that:
- Connects to GitHub using a GitHub App
- Finds pull requests that haven’t been updated in a few days
- Sends a single, clean reminder to Slack
- Avoids repeating the same reminders using a KV store
All of this runs as a single scheduled task with one Lua script.
Step 1: Create a GitHub App
Create a new GitHub App from your GitHub settings.
Make sure to:
- Enable user authorization (OAuth)
- Install the app on the account or organization that owns your repository
- Give it read access to Pull Requests
- Limit repository access to only what you need
GitHub App tokens are scoped by both the app and the user, which keeps things nicely secure and avoids over permissioned integrations.
Step 2: Save the GitHub credential in RapidForge
In RapidForge, create a new OAuth credential with the following values:
Name: GITHUB_PR_BOT
Client ID: your GitHub App client ID
Client Secret: your GitHub App client secret
Scope: leave empty
Authorization URL: https://github.com/login/oauth/authorize
Token URL: https://github.com/login/oauth/access_token
You can watch a video here to see how to create oauth apps in Rapidforge
RapidForge will give you a callback URL. Copy that into your GitHub App settings.
Once authorized, Rapidforge will inject access token via environment variable:
CRED_GITHUB_PR_BOT
Don't worry editor has auto complete to aid you.
Step 3: Save the Slack webhook
Create an incoming webhook in Slack and store it as a text credential in RapidForge:
Name: SLACK_WEBHOOK
This will be available in your script as: CRED_SLACK_WEBHOOK
Step 4: Create the periodic task
Lets create periodic task with following parameters
A good starting schedule (weekday mornings) is:
0 9 * * 1-5
Add these environment variables:
GITHUB_OWNER=your-org
GITHUB_REPO=your-repo
STALE_DAYS=3
REMINDER_COOLDOWN_HOURS=24
SKIP_DRAFTS=true
Step 5: Add the Lua script
Rapidforge allows you to write logic in Bash or Lua. However, if you are more confortable with another language you can always use it with shebang. (I.e #!/usr/bin/node) Just make sure host machine has the interpreter installed in that case. Lua is embeded into Rapidforge so it does not required to be installed
local json = require("json")
local http = require("http")
local token = os.getenv("CRED_GITHUB_PR_BOT")
local slackWebhook = os.getenv("CRED_SLACK_WEBHOOK")
local owner = os.getenv("GITHUB_OWNER")
local repo = os.getenv("GITHUB_REPO")
local rapidforgeBin = os.getenv("RAPIDFORGE_BIN") or "./rapidforge"
local staleDays = tonumber(os.getenv("STALE_DAYS") or "3")
local reminderCooldownHours = tonumber(os.getenv("REMINDER_COOLDOWN_HOURS") or "24")
local skipDrafts = (os.getenv("SKIP_DRAFTS") or "true") == "true"
local function fail(message)
io.stderr:write(message .. "\n")
os.exit(1)
end
if not token or token == "" then fail("Missing CRED_GITHUB_PR_BOT") end
if not slackWebhook or slackWebhook == "" then fail("Missing CRED_SLACK_WEBHOOK") end
if not owner or owner == "" then fail("Missing GITHUB_OWNER") end
if not repo or repo == "" then fail("Missing GITHUB_REPO") end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function shell(command)
local handle = io.popen(command)
if not handle then
return nil
end
local output = handle:read("*a") or ""
local ok, _, code = handle:close()
return trim(output), ok, code
end
local function shellQuote(value)
return string.format("%q", tostring(value))
end
local function kvGet(key)
local command = string.format(
"%s kv get %s 2>/dev/null",
shellQuote(rapidforgeBin),
shellQuote(key)
)
local result = shell(command)
if not result or result == "" then
return nil
end
return result
end
local function kvSet(key, value)
local command = string.format(
"%s kv set %s %s",
shellQuote(rapidforgeBin),
shellQuote(key),
shellQuote(value)
)
local _, ok = shell(command)
if not ok then
fail("Failed to store reminder state in RapidForge KV")
end
end
local function parseGithubTime(value)
local year, month, day, hour, min, sec = value:match("^(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)Z$")
if not year then
return nil
end
return os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = tonumber(hour),
min = tonumber(min),
sec = tonumber(sec)
})
end
local function hoursSince(timestamp)
return math.floor((os.time() - timestamp) / 3600)
end
local function githubGet(path)
local body, status = http.get(
"https://api.github.com" .. path,
{
["Accept"] = "application/vnd.github+json",
["Authorization"] = "Bearer " .. token,
["X-GitHub-Api-Version"] = "2022-11-28",
["User-Agent"] = "rapidforge-pr-reminder"
}
)
if status ~= 200 then
fail("GitHub API request failed with status " .. tostring(status) .. ": " .. tostring(body))
end
return json.decode(body)
end
local function sendSlackMessage(text)
local payload = json.encode({ text = text })
local body, status = http.post(
slackWebhook,
payload,
{ ["Content-Type"] = "application/json" }
)
if status < 200 or status >= 300 then
fail("Slack webhook failed with status " .. tostring(status) .. ": " .. tostring(body))
end
end
local pulls = githubGet(string.format("/repos/%s/%s/pulls?state=open&per_page=100", owner, repo))
local stalePulls = {}
for _, pr in ipairs(pulls) do
if (not skipDrafts) or (pr.draft ~= true) then
local updatedAt = parseGithubTime(pr.updated_at)
if updatedAt then
local inactiveHours = hoursSince(updatedAt)
local staleHours = staleDays * 24
if inactiveHours >= staleHours then
local reminderKey = string.format("pr-reminder:%s:%s:%s", owner, repo, pr.number)
local lastReminder = tonumber(kvGet(reminderKey) or "0")
local cooldownSeconds = reminderCooldownHours * 3600
if lastReminder == 0 or (os.time() - lastReminder) >= cooldownSeconds then
table.insert(stalePulls, {
number = pr.number,
title = pr.title,
url = pr.html_url,
author = pr.user and pr.user.login or "unknown",
inactiveHours = inactiveHours,
reminderKey = reminderKey
})
end
end
end
end
end
if #stalePulls == 0 then
print("No stale pull requests found")
return
end
table.sort(stalePulls, function(a, b)
return a.inactiveHours > b.inactiveHours
end)
local lines = {
string.format("Open pull requests without updates for %d+ days in %s/%s:", staleDays, owner, repo),
""
}
for _, pr in ipairs(stalePulls) do
local inactiveDays = math.floor(pr.inactiveHours / 24)
table.insert(lines, string.format("• #%d %s — %s days idle — %s", pr.number, pr.title, inactiveDays, pr.url))
end
sendSlackMessage(table.concat(lines, "\n"))
local now = tostring(os.time())
for _, pr in ipairs(stalePulls) do
kvSet(pr.reminderKey, now)
end
print("Sent Slack reminder for " .. tostring(#stalePulls) .. " pull request(s)")
Optional: Failure alerts
Rapidforge allows you to add failure hook to notify Slack if something breaks:
ERROR_MSG="${FAILURE_ERROR:-$FAILURE_OUTPUT}"
ERROR_TRUNC=$(printf "%s" "$ERROR_MSG" | head -c 1800)
if [ -n "$CRED_SLACK_WEBHOOK" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"❌ GitHub PR reminder failed\nTask ID: ${TASK_ID}\nExit code: ${FAILURE_EXIT_CODE}\n\n${ERROR_TRUNC}\"}" \
"$CRED_SLACK_WEBHOOK"
fi
That’s it. Hopefully this gives you a practical way to keep pull requests from going quiet without adding more manual work. If you’re interested in building similar small automations, you can check out RapidForge. There are a few more use cases there that follow the same idea of turning simple scripts into useful tools.


Top comments (0)