DEV Community

Cover image for Your Pull Requests Are Being Ignored. Fix It with This Simple Bot
Can Eldem
Can Eldem

Posted on

Your Pull Requests Are Being Ignored. Fix It with This Simple Bot

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

Oauth app parameters for Rapidforge to save access key

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

Periodic task creation ui

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)")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)