id: github-stale-pull-request-alert
namespace: company.team
description: |
Audit open GitHub pull requests for inactivity against a configurable SLA,
generate a downloadable markdown audit report artifact, and alert engineering
teams via Slack when pull requests languish without review.
triggers:
- id: scheduled_audit
type: io.kestra.plugin.core.trigger.Schedule
description: Periodic scheduled audit. Shipped disabled by default.
cron: "0 9 * * 1-5"
disabled: true
- id: manual_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: Authenticated webhook trigger for on-demand CI/CD or chatbot execution.
key: stale-pr-audit
inputs:
- id: repository
type: STRING
displayName: GitHub Repository
description: Target repository in 'owner/repo' format (e.g. kestra-io/blueprints).
defaults: kestra-io/blueprints
- id: stale_days
type: INT
displayName: Stale Threshold (Days)
description: Number of days without updates before a pull request is classified
as stale.
defaults: 5
- id: max_prs
type: INT
displayName: Maximum Pull Requests
description: Maximum number of open pull requests to inspect.
defaults: 30
- id: notify_slack
type: BOOL
displayName: Notify Slack
description: Send a Slack notification if any stale pull requests are detected.
defaults: true
tasks:
- id: fetch_open_prs
type: io.kestra.plugin.core.http.Request
description: Query the GitHub REST API for open pull requests sorted by least
recently updated.
uri: "https://api.github.com/repos/{{ inputs.repository
}}/pulls?state=open&per_page={{ inputs.max_prs
}}&sort=updated&direction=asc"
method: GET
headers:
Accept: "application/vnd.github+json"
User-Agent: "Kestra-Stale-PR-Audit"
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
- id: analyze_pull_requests
type: io.kestra.plugin.scripts.python.Script
description: Parse pull requests, filter out drafts, calculate inactivity days
against the SLA threshold, generate a markdown report artifact, and
publish metrics.
containerImage: python:3.11-slim
inputFiles:
prs.json: "{{ outputs.fetch_open_prs.body | toJson }}"
outputFiles:
- stale-pr-report.md
script: |
import json
from datetime import datetime, timezone
with open("prs.json") as f:
raw = f.read().strip()
prs_data = json.loads(raw) if raw else []
if isinstance(prs_data, str):
prs_data = json.loads(prs_data)
stale_days = int("{{ inputs.stale_days }}")
now = datetime.now(timezone.utc)
total_inspected = len(prs_data) if isinstance(prs_data, list) else 0
stale_prs = []
active_prs = 0
draft_prs = 0
if isinstance(prs_data, list):
for pr in prs_data:
if pr.get("draft", False):
draft_prs += 1
continue
updated_at_str = pr.get("updated_at")
if not updated_at_str:
continue
updated_at = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00"))
inactivity_days = (now - updated_at).days
if inactivity_days >= stale_days:
stale_prs.append({
"number": pr.get("number"),
"title": pr.get("title", ""),
"author": pr.get("user", {}).get("login", "unknown") if pr.get("user") else "unknown",
"url": pr.get("html_url", ""),
"inactivity_days": inactivity_days,
"updated_at": updated_at_str
})
else:
active_prs += 1
stale_prs.sort(key=lambda x: x["inactivity_days"], reverse=True)
stale_count = len(stale_prs)
oldest_inactivity = stale_prs[0]["inactivity_days"] if stale_prs else 0
report_lines = [
"# Stale Pull Request Audit Report",
f"**Repository:** `{{ inputs.repository }}` ",
f"**Audit Timestamp:** `{now.strftime('%Y-%m-%d %H:%M:%S UTC')}` ",
f"**Inactivity SLA Threshold:** `{stale_days} days` ",
"",
"## Summary Metrics",
f"- **Total Open PRs Inspected:** {total_inspected}",
f"- **Active PRs (within SLA):** {active_prs}",
f"- **Draft PRs (skipped):** {draft_prs}",
f"- **Stale PRs (breached SLA):** {stale_count}",
f"- **Oldest Inactive PR:** {oldest_inactivity} days",
"",
]
if stale_prs:
report_lines.append("## Stale Pull Requests Requiring Attention")
report_lines.append("| PR # | Title | Author | Inactive Days | Link |")
report_lines.append("|---|---|---|---|---|")
for pr in stale_prs:
clean_title = str(pr['title']).replace('|', '-').replace('\n', ' ')
report_lines.append(f"| #{pr['number']} | {clean_title} | @{pr['author']} | {pr['inactivity_days']} | [View PR]({pr['url']}) |")
else:
report_lines.append("## Status: Clean")
report_lines.append("All open pull requests have recent activity within the SLA threshold.")
with open("stale-pr-report.md", "w") as f:
f.write("\n".join(report_lines) + "\n")
slack_items = []
for pr in stale_prs[:5]:
slack_items.append(f"• <{pr['url']}|#{pr['number']}: {pr['title']}> by @{pr['author']} ({pr['inactivity_days']}d idle)")
if len(stale_prs) > 5:
slack_items.append(f"• ... and {len(stale_prs) - 5} more stale pull requests.")
outputs = {
"total_inspected": total_inspected,
"stale_count": stale_count,
"active_count": active_prs,
"draft_count": draft_prs,
"oldest_inactivity_days": oldest_inactivity,
"slack_summary": "\n".join(slack_items) if slack_items else "None"
}
print("::" + json.dumps({"outputs": outputs}) + "::")
- id: evaluate_stale_prs
type: io.kestra.plugin.core.flow.If
description: Checks if any stale pull requests were detected and notification is
requested.
condition: "{{ outputs.analyze_pull_requests.vars.stale_count > 0 and
inputs.notify_slack }}"
then:
- id: alert_stale_prs
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Posts an actionable stale PR digest to Slack.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "⚠️ *Stale Pull Request Alert for `{{ inputs.repository }}`*",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "⚠️ Stale Pull Request SLA Alert"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Repository:* `{{ inputs.repository }}`\n*Stale PR Count:* {{ outputs.analyze_pull_requests.vars.stale_count }} of {{ outputs.analyze_pull_requests.vars.total_inspected }} inspected\n*Oldest Inactive:* {{ outputs.analyze_pull_requests.vars.oldest_inactivity_days }} days idle (SLA: {{ inputs.stale_days }} days)"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Dormant Pull Requests:*\n{{ outputs.analyze_pull_requests.vars.slack_summary }}"
}
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Audit execution: {{ execution.id }} | Report artifact generated: `stale-pr-report.md`"
}
]
}
]
}
else:
- id: log_healthy
type: io.kestra.plugin.core.log.Log
description: Logs clean status when all pull requests are active within SLA.
message: "Clean pull request audit for {{ inputs.repository }}: all {{
outputs.analyze_pull_requests.vars.total_inspected }} open PRs are
active within the {{ inputs.stale_days }}-day SLA."
errors:
- id: alert_audit_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alerts Slack on-call channel if the GitHub API audit fails.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "🚨 Stale PR audit failed for {{ inputs.repository }} in execution {{ execution.id }}. Check GitHub token permissions or rate limits."
}
outputs:
- id: stale_count
type: INT
value: "{{ outputs.analyze_pull_requests.vars.stale_count }}"
- id: total_inspected
type: INT
value: "{{ outputs.analyze_pull_requests.vars.total_inspected }}"
- id: report_artifact
type: STRING
value: "{{ outputs.analyze_pull_requests.outputFiles['stale-pr-report.md'] }}"