Skip to content

Commit c3462f6

Browse files
authored
feat: add PR inactivity reminder bot for stale pull requests (#978)
Signed-off-by: MonaaEid <monaa_eid@hotmail.com> Signed-off-by: MontyPokemon <59332150+MonaaEid@users.noreply.github.com>
1 parent cee7a49 commit c3462f6

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// A script to remind PR authors of inactivity by posting a comment.
2+
3+
// DRY_RUN env var: any case-insensitive 'true' value will enable dry-run
4+
const dryRun = (process.env.DRY_RUN || 'false').toString().toLowerCase() === 'true';
5+
6+
// Helper to resolve the head repo of a PR
7+
function resolveHeadRepo(pr, defaultOwner, defaultRepo) {
8+
return {
9+
owner: pr.head.repo?.owner?.login || defaultOwner,
10+
repo: pr.head.repo?.name || defaultRepo,
11+
};
12+
}
13+
14+
// Helper to get the last commit date of a PR
15+
async function getLastCommitDate(github, pr, owner, repo) {
16+
const { owner: headRepoOwner, repo: headRepoName } = resolveHeadRepo(pr, owner, repo);
17+
try {
18+
const commitRes = await github.rest.repos.getCommit({
19+
owner: headRepoOwner,
20+
repo: headRepoName,
21+
ref: pr.head.sha,
22+
});
23+
const commit = commitRes.data?.commit ?? null;
24+
return new Date(commit?.author?.date || commit?.committer?.date || pr.created_at);
25+
} catch (getCommitErr) {
26+
console.log(`Failed to fetch head commit ${pr.head.sha} for PR #${pr.number}:`, getCommitErr.message || getCommitErr);
27+
return null; // Signal fallback needed
28+
}
29+
}
30+
31+
32+
// Look for an existing bot comment using our unique marker.
33+
async function hasExistingBotComment(github, pr, owner, repo, marker) {
34+
try {
35+
const comments = await github.paginate(github.rest.issues.listComments, {
36+
owner,
37+
repo,
38+
issue_number: pr.number,
39+
per_page: 100,
40+
});
41+
return comments.find(c => c.body && c.body.includes(marker)) || false;
42+
} catch (err) {
43+
console.log(`Failed to list comments for PR #${pr.number}:`, err.message || err);
44+
return null; // Prevent duplicate comment if we cannot check
45+
}
46+
}
47+
48+
// Helper to post an inactivity comment
49+
async function postInactivityComment(github, pr, owner, repo, marker, inactivityDays, discordLink, office_hours_calendar) {
50+
const comment = `${marker}
51+
Hi @${pr.user.login},\n\nThis pull request has had no commit activity for ${inactivityDays} days. Are you still working on the issue? please push a commit to keep the PR active or it will be closed due to inactivity.
52+
Reach out on discord or join our office hours if you need assistance.\n\n- ${discordLink}\n- ${office_hours_calendar} \n\nFrom the Python SDK Team`;
53+
if (dryRun) {
54+
console.log(`DRY-RUN: Would comment on PR #${pr.number} (${pr.html_url}) with body:\n---\n${comment}\n---`);
55+
return true;
56+
}
57+
58+
try {
59+
await github.rest.issues.createComment({
60+
owner,
61+
repo,
62+
issue_number: pr.number,
63+
body: comment,
64+
});
65+
console.log(`Commented on PR #${pr.number} (${pr.html_url})`);
66+
return true;
67+
} catch (commentErr) {
68+
console.log(`Failed to comment on PR #${pr.number}:`, commentErr);
69+
return false;
70+
}
71+
}
72+
73+
// Main module function
74+
module.exports = async ({github, context}) => {
75+
const inactivityThresholdDays = 10; // days of inactivity before commenting
76+
const cutoff = new Date(Date.now() - inactivityThresholdDays * 24 * 60 * 60 * 1000);
77+
const owner = context.repo.owner;
78+
const repo = context.repo.repo;
79+
const discordLink = `[Discord](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/docs/discord.md)`;
80+
const office_hours_calendar =`[Office Hours](https://zoom-lfx.platform.linuxfoundation.org/meetings/hiero?view=week)`;
81+
// Unique marker so we can find the bot's own comment later.
82+
const marker = '<!-- pr-inactivity-bot-marker -->';
83+
84+
if (dryRun) {
85+
console.log('Running in DRY-RUN mode: no comments will be posted.');
86+
}
87+
88+
let commentedCount = 0;
89+
let skippedCount = 0;
90+
91+
const prs = await github.paginate(github.rest.pulls.list, {
92+
owner,
93+
repo,
94+
state: 'open',
95+
per_page: 100,
96+
});
97+
98+
for (const pr of prs) {
99+
// 1. Check inactivity
100+
const lastCommitDate = await getLastCommitDate(github, pr, owner, repo);
101+
const inactivityDays = Math.floor((Date.now() - (lastCommitDate ? lastCommitDate.getTime() : new Date(pr.created_at).getTime())) / (1000 * 60 * 60 * 24));
102+
103+
104+
if (lastCommitDate > cutoff) {
105+
skippedCount++;
106+
console.log(`PR #${pr.number} has recent commit on ${lastCommitDate.toISOString()} - skipping`);
107+
continue;
108+
}
109+
110+
// 2. Check for existing comment
111+
const existingBotComment = await hasExistingBotComment(github, pr, owner, repo, marker);
112+
if (existingBotComment) {
113+
skippedCount++;
114+
const idInfo = existingBotComment && existingBotComment.id ? existingBotComment.id : '(unknown)';
115+
console.log(`PR #${pr.number} already has an inactivity comment (id: ${idInfo}) - skipping`);
116+
continue;
117+
}
118+
119+
// 3. Post inactivity comment
120+
const commented = await postInactivityComment(github, pr, owner, repo, marker, inactivityDays, discordLink, office_hours_calendar);
121+
if (commented) commentedCount++;
122+
}
123+
124+
console.log("=== Summary ===");
125+
console.log(`PRs commented: ${commentedCount}`);
126+
console.log(`PRs skipped (existing comment present): ${skippedCount}`);
127+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# This workflow warns PRs that have had no activity for a specified amount of time(10 Days).
2+
3+
name: PR Inactivity Reminder Bot
4+
5+
on:
6+
schedule:
7+
- cron: '0 11 * * *'
8+
workflow_dispatch:
9+
inputs:
10+
dry_run:
11+
description: 'If true, do not post comments (dry run). Accepts "true" or "false". Default true for manual runs.'
12+
required: false
13+
default: 'true'
14+
15+
permissions:
16+
pull-requests: write
17+
issues: write
18+
contents: read
19+
20+
21+
jobs:
22+
remind_inactive_prs:
23+
runs-on: ubuntu-latest
24+
env:
25+
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
26+
27+
steps:
28+
- name: Harden the runner
29+
uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3
30+
with:
31+
egress-policy: audit
32+
- name: Checkout repository
33+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
34+
- name: Remind authors of inactive PRs
35+
env:
36+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37+
DRY_RUN: ${{ env.DRY_RUN }}
38+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
39+
with:
40+
script: |
41+
const script = require('./.github/scripts/pr_inactivity_reminder.js')
42+
await script({ github, context });

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2323
- Restore bug and feature request issue templates (#996)(https://github.com/hiero-ledger/hiero-sdk-python/issues/996)
2424
- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362)
2525
- Add TLS support with two-stage control (`set_transport_security()` and `set_verify_certificates()`) for encrypted connections to Hedera networks. TLS is enabled by default for hosted networks (mainnet, testnet, previewnet) and disabled for local networks (solo, localhost) (#855)
26+
- Add PR inactivity reminder bot for stale pull requests `.github/workflows/pr-inactivity-reminder-bot.yml`
2627

2728
### Changed
2829

0 commit comments

Comments
 (0)